commit a1293723eb4b0cae85f291ac98a7f6591b4b996a Author: lixin Date: Thu Jan 9 16:10:19 2025 +0800 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..857ff6a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +logs/ +npm-debug.log +yarn-error.log +node_modules/ +package-lock.json +yarn.lock +coverage/ +dist/ +.idea/ +run/ +.DS_Store +*.sw* +*.un~ +.tsbuildinfo +.tsbuildinfo.* +.audit +typings/ +public/uploads/ +cache/ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4c7f8a8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# 🎨 editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true +insert_final_newline = true \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..7dab201 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,30 @@ +{ + "extends": "./node_modules/mwts/", + "ignorePatterns": [ + "node_modules", + "dist", + "test", + "jest.config.js", + "typings", + "public/**/**", + "view/**/**", + "packages" + ], + "env": { + "jest": true + }, + "rules": { + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/ban-ts-comment": "off", + "node/no-extraneous-import": "off", + "no-empty": "off", + "node/no-extraneous-require": "off", + "node/no-unpublished-import": "off", + "eqeqeq": "off", + "node/no-unsupported-features/node-builtins": "off", + "@typescript-eslint/ban-types": "off", + "no-control-regex": "off", + "prefer-const": "off" + } +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0b909d8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.js text eol=lf +*.json text eol=lf +*.ts text eol=lf +*.code-snippets text eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4cafa26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +logs/ +cache/ +npm-debug.log +yarn-error.log +node_modules/ +package-lock.json +yarn.lock +coverage/ +dist/ +.idea/ +run/ +.DS_Store +launch.json +*.sw* +*.un~ +.tsbuildinfo +.tsbuildinfo.* +data/* +pnpm-lock.yaml +public/uploads/* diff --git a/.hintrc b/.hintrc new file mode 100644 index 0000000..82cba57 --- /dev/null +++ b/.hintrc @@ -0,0 +1,9 @@ +{ + "extends": [ + "development" + ], + "hints": { + "typescript-config/consistent-casing": "off", + "typescript-config/strict": "off" + } +} \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..b964930 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,3 @@ +module.exports = { + ...require('mwts/.prettierrc.json') +} diff --git a/.vscode/config.code-snippets b/.vscode/config.code-snippets new file mode 100644 index 0000000..2abbf4f --- /dev/null +++ b/.vscode/config.code-snippets @@ -0,0 +1,28 @@ +{ + "config": { + "prefix": "config", + "body": [ + "import { ModuleConfig } from '@cool-midway/core';", + "", + "/**", + " * 模块配置", + " */", + "export default () => {", + " return {", + " // 模块名称", + " name: 'xxx',", + " // 模块描述", + " description: 'xxx',", + " // 中间件,只对本模块有效", + " middlewares: [],", + " // 中间件,全局有效", + " globalMiddlewares: [],", + " // 模块加载顺序,默认为0,值越大越优先加载", + " order: 0,", + " } as ModuleConfig;", + "};", + "" + ], + "description": "cool-admin config代码片段" + } +} diff --git a/.vscode/controller.code-snippets b/.vscode/controller.code-snippets new file mode 100644 index 0000000..5bd7095 --- /dev/null +++ b/.vscode/controller.code-snippets @@ -0,0 +1,19 @@ +{ + "controller": { + "prefix": "controller", + "body": [ + "import { CoolController, BaseController } from '@cool-midway/core';", + "", + "/**", + " * 描述", + " */", + "@CoolController({", + " api: ['add', 'delete', 'update', 'info', 'list', 'page'],", + " entity: 实体,", + "})", + "export class XxxController extends BaseController {}", + "" + ], + "description": "cool-admin controller代码片段" + } +} diff --git a/.vscode/entity.code-snippets b/.vscode/entity.code-snippets new file mode 100644 index 0000000..992a066 --- /dev/null +++ b/.vscode/entity.code-snippets @@ -0,0 +1,20 @@ +{ + "entity": { + "prefix": "entity", + "body": [ + "import { BaseEntity } from '@cool-midway/core';", + "import { Column, Entity } from 'typeorm';", + "", + "/**", + " * 描述", + " */", + "@Entity('xxx_xxx_xxx')", + "export class XxxEntity extends BaseEntity {", + " @Column({ comment: '描述' })", + " xxx: string;", + "}", + "" + ], + "description": "cool-admin entity代码片段" + } +} diff --git a/.vscode/event.code-snippets b/.vscode/event.code-snippets new file mode 100644 index 0000000..dae6473 --- /dev/null +++ b/.vscode/event.code-snippets @@ -0,0 +1,21 @@ +{ + "event": { + "prefix": "event", + "body": [ + "import { CoolEvent, Event } from '@cool-midway/core';", + "", + "/**", + " * 接收事件", + " */", + "@CoolEvent()", + "export class xxxEvent {", + " @Event('updateUser')", + " async updateUser(msg, a) {", + " console.log('ImEvent', 'updateUser', msg, a);", + " }", + "}", + "" + ], + "description": "cool-admin event代码片段" + } +} diff --git a/.vscode/middleware.code-snippets b/.vscode/middleware.code-snippets new file mode 100644 index 0000000..367be90 --- /dev/null +++ b/.vscode/middleware.code-snippets @@ -0,0 +1,29 @@ +{ + "middleware": { + "prefix": "middleware", + "body": [ + "import { Middleware } from '@midwayjs/decorator';", + "import { NextFunction, Context } from '@midwayjs/koa';", + "import { IMiddleware } from '@midwayjs/core';", + "", + "/**", + " * 描述", + " */", + "@Middleware()", + "export class XxxMiddleware implements IMiddleware {", + " resolve() {", + " return async (ctx: Context, next: NextFunction) => {", + " // 控制器前执行的逻辑", + " const startTime = Date.now();", + " // 执行下一个 Web 中间件,最后执行到控制器", + " await next();", + " // 控制器之后执行的逻辑", + " console.log(Date.now() - startTime);", + " };", + " }", + "}", + "" + ], + "description": "cool-admin middleware代码片段" + } +} diff --git a/.vscode/queue.code-snippets b/.vscode/queue.code-snippets new file mode 100644 index 0000000..3518633 --- /dev/null +++ b/.vscode/queue.code-snippets @@ -0,0 +1,21 @@ +{ + "queue": { + "prefix": "queue", + "body": [ + "import { BaseCoolQueue, CoolQueue } from '@cool-midway/task';", + "", + "/**", + " * 队列", + " */", + "@CoolQueue()", + "export abstract class xxxQueue extends BaseCoolQueue {", + " async data(job: any, done: any) {", + " console.log('收到的数据', job.data);", + " done();", + " }", + "}", + "" + ], + "description": "cool-admin service代码片段" + } +} diff --git a/.vscode/service.code-snippets b/.vscode/service.code-snippets new file mode 100644 index 0000000..7596795 --- /dev/null +++ b/.vscode/service.code-snippets @@ -0,0 +1,34 @@ +{ + "service": { + "prefix": "service", + "body": [ + "import { Init, Provide } from '@midwayjs/decorator';", + "import { BaseService } from '@cool-midway/core';", + "import { InjectEntityModel } from '@midwayjs/typeorm';", + "import { Repository } from 'typeorm';", + "", + "/**", + " * 描述", + " */", + "@Provide()", + "export class XxxService extends BaseService {", + " @InjectEntityModel(实体)", + " xxxEntity: Repository<实体>;", + "" + " @Init()" + " async init() {", + " await super.init();", + " this.setEntity(this.xxxEntity);", + " }", + "", + " /**", + " * 描述", + " */", + " async xxx() {}", + "}", + "" + ], + "description": "cool-admin service代码片段" + } +} + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..50677b4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ + +FROM node:lts-alpine + +WORKDIR /app + +# 配置alpine国内镜像加速 +RUN sed -i "s@http://dl-cdn.alpinelinux.org/@https://repo.huaweicloud.com/@g" /etc/apk/repositories + +# 安装tzdata,默认的alpine基础镜像不包含时区组件,安装后可通过TZ环境变量配置时区 +RUN apk add --no-cache tzdata + +# 设置时区为中国东八区,这里的配置可以被docker-compose.yml或docker run时指定的时区覆盖 +ENV TZ="Asia/Shanghai" + +# 如果各公司有自己的私有源,可以替换registry地址,如使用官方源注释下一行 +RUN npm config set registry https://registry.npm.taobao.org + +# 安装开发期依赖 +COPY package.json ./package.json +RUN npm install +# 构建项目 +COPY . . +RUN npm run build +# 删除开发期依赖 +RUN rm -rf node_modules && rm package-lock.json +# 安装生产环境依赖 +RUN npm install --production + +# 如果端口更换,这边可以更新一下 +EXPOSE 8001 + +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5a6831d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 cool-team-official + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac1aba6 --- /dev/null +++ b/README.md @@ -0,0 +1,167 @@ +

+ Midway Logo +

+ +

cool-admin(midway版)一个很酷的后台权限管理系统,开源免费,模块化、插件化、极速开发CRUD,方便快速构建迭代后台管理系统,支持serverless、docker、普通服务器等多种方式部署 +到 官网 进一步了解。 +

+ GitHub license + GitHub tag + GitHub tag +

+ +## 技术栈 + +- 后端:**`node.js` `midway.js` `koa.js` `typescript`** +- 前端:**`vue.js` `element-plus` `jsx` `pinia` `vue-router`** +- 数据库:**`mysql` `postgresql` `sqlite`** + +如果你是前端,后端的这些技术选型对你是特别友好的,前端开发者可以较快速地上手。 +如果你是后端,Typescript 的语法又跟 java、php 等特别类似,一切看起来也是那么得熟悉。 + +如果你想使用java版本后端,请移步[cool-admin-java](https://cool-js.com/admin/java/introduce.html) + +#### 官网 + +[https://cool-js.com](https://cool-js.com) + + +## 演示 + +[AI极速编码](https://cool-js.com/ai/introduce.html) + +[https://show.cool-admin.com](https://show.cool-admin.com) + +- 账户:admin +- 密码:123456 + +Admin Home + + +#### 项目前端 + +[https://github.com/cool-team-official/cool-admin-vue](https://github.com/cool-team-official/cool-admin-vue) + +## 微信群 + +Admin Wechat + +## 运行 + +#### 修改数据库配置,配置文件位于`src/config/config.local.ts` + +以Mysql为例,其他数据库请参考[数据库配置文档](https://cool-js.com/admin/node/quick.html#%E6%95%B0%E6%8D%AE%E5%BA%93%E9%85%8D%E7%BD%AE) + +Mysql(`>=5.7版本`),建议 8.0,node 版本(`>=16.x`),建议 18.x,首次启动会自动初始化并导入数据 + +```ts +// mysql,驱动已经内置,无需安装 +typeorm: { + dataSource: { + default: { + type: 'mysql', + host: '127.0.0.1', + port: 3306, + username: 'root', + password: '123456', + database: 'cool', + // 自动建表 注意:线上部署的时候不要使用,有可能导致数据丢失 + synchronize: true, + // 打印日志 + logging: false, + // 字符集 + charset: 'utf8mb4', + // 是否开启缓存 + cache: true, + // 实体路径 + entities: ['**/modules/*/entity'], + }, + }, + }, +``` +#### 安装依赖并运行 + +```bash +$ npm i +$ npm run dev +$ open http://localhost:8001/ +``` + +注: `npm i`如果安装失败可以尝试使用[cnpm](https://developer.aliyun.com/mirror/NPM?from=tnpm),或者切换您的镜像源,推荐使用[pnpm](https://pnpm.io/) + +## CURD(快速增删改查) + +大部分的后台管理系统,或者 API 服务都是对数据进行管理,所以可以看到大量的 CRUD 场景(增删改查),cool-admin 对此进行了大量地封装,让这块的编码量变得极其地少。 + +#### 新建一个数据表 + +`src/modules/demo/entity/goods.ts`,项目启动数据库会自动创建该表,无需手动创建 + +```ts +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 商品 + */ +@Entity('demo_app_goods') +export class DemoAppGoodsEntity extends BaseEntity { + @Column({ comment: '标题' }) + title: string; + + @Column({ comment: '图片' }) + pic: string; + + @Column({ comment: '价格', type: 'decimal', precision: 5, scale: 2 }) + price: number; +} +``` + +#### 编写 api 接口 + +`src/modules/demo/controller/app/goods.ts`,快速编写 6 个 api 接口 + +```ts +import { CoolController, BaseController } from '@cool-midway/core'; +import { DemoAppGoodsEntity } from '../../entity/goods'; + +/** + * 商品 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: DemoAppGoodsEntity, +}) +export class DemoAppGoodsController extends BaseController { + /** + * 其他接口 + */ + @Get('/other') + async other() { + return this.ok('hello, cool-admin!!!'); + } +} +``` + +这样我们就完成了 6 个接口的编写,对应的接口如下: + +- `POST /app/demo/goods/add` 新增 +- `POST /app/demo/goods/delete` 删除 +- `POST /app/demo/goods/update` 更新 +- `GET /app/demo/goods/info` 单个信息 +- `POST /app/demo/goods/list` 列表信息 +- `POST /app/demo/goods/page` 分页查询(包含模糊查询、字段全匹配等) + +### 部署 + +[部署教程](https://cool-js.com/admin/node/other/deploy.html) + +### 内置指令 + +- 使用 `npm run lint` 来做代码风格检查。 + +[midway]: https://midwayjs.org + +### 低价服务器 + +[阿里云、腾讯云、华为云低价云服务器,不限新老](https://cool-js.com/ad/server.html) diff --git a/bootstrap.js b/bootstrap.js new file mode 100644 index 0000000..9c096c2 --- /dev/null +++ b/bootstrap.js @@ -0,0 +1,2 @@ +const { Bootstrap } = require('@midwayjs/bootstrap'); +Bootstrap.run(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bf8115c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +# 本地数据库环境 +# 数据存放在当前目录下的 data里 +# 推荐使用安装了docker扩展的vscode打开目录 在本文件上右键可以快速启动,停止 +# 如不需要相关容器开机自启动,可注释掉 restart: always +# 如遇端口冲突 可调整ports下 :前面的端口号 +version: "3.1" + +services: + coolDB: + image: mysql + command: + --default-authentication-plugin=mysql_native_password + --sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION + --group_concat_max_len=102400 + restart: always + volumes: + - ./data/mysql/:/var/lib/mysql/ + environment: + TZ: Asia/Shanghai # 指定时区 + MYSQL_ROOT_PASSWORD: "123456" # 配置root用户密码 + MYSQL_DATABASE: "cool" # 业务库名 + MYSQL_USER: "root" # 业务库用户名 + MYSQL_PASSWORD: "123456" # 业务库密码 + ports: + - 3306:3306 + + coolRedis: + image: redis + #command: --requirepass "12345678" # redis库密码,不需要密码注释本行 + restart: always + environment: + TZ: Asia/Shanghai # 指定时区 + volumes: + - ./data/redis/:/data/ + ports: + - 6379:6379 diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..c5bd388 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testPathIgnorePatterns: ['/test/fixtures'], + coveragePathIgnorePatterns: ['/test/'], +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..ed29f97 --- /dev/null +++ b/package.json @@ -0,0 +1,83 @@ +{ + "name": "cool-admin", + "version": "7.1.0", + "description": "一个项目用COOL就够了", + "private": true, + "dependencies": { + "@cool-midway/core": "^7.1.19", + "@cool-midway/rpc": "^7.0.0", + "@cool-midway/task": "^7.0.0", + "@midwayjs/bootstrap": "^3.15.0", + "@midwayjs/cache-manager": "^3.15.5", + "@midwayjs/core": "^3.15.0", + "@midwayjs/cron": "^3.15.2", + "@midwayjs/cross-domain": "^3.15.2", + "@midwayjs/decorator": "^3.15.0", + "@midwayjs/info": "^3.15.2", + "@midwayjs/koa": "^3.15.2", + "@midwayjs/logger": "^3.3.0", + "@midwayjs/socketio": "^3.15.2", + "@midwayjs/static-file": "^3.15.2", + "@midwayjs/typeorm": "^3.15.2", + "@midwayjs/upload": "^3.15.2", + "@midwayjs/validate": "^3.15.2", + "@midwayjs/view-ejs": "^3.15.2", + "@socket.io/redis-adapter": "^8.3.0", + "axios": "^1.6.8", + "bignumber.js": "^9.1.2", + "cache-manager-ioredis-yet": "^2.0.2", + "decompress": "^4.2.1", + "download": "^8.0.0", + "ioredis": "^5.3.2", + "ipip-ipdb": "^0.6.0", + "jsonwebtoken": "^9.0.2", + "lodash": "^4.17.21", + "md5": "^2.3.0", + "mini-svg-data-uri": "^1.4.4", + "moment": "^2.30.1", + "mysql2": "^3.9.2", + "semver": "^7.6.0", + "svg-captcha": "^1.4.0", + "svg2png-wasm": "^1.4.1", + "typeorm": "^0.3.20", + "uuid": "^9.0.1", + "ws": "^8.16.0" + }, + "devDependencies": { + "@midwayjs/cli": "^2.1.1", + "@midwayjs/mock": "^3.15.2", + "@types/jest": "^29.5.12", + "@types/koa": "^2.15.0", + "@types/node": "20", + "cross-env": "^7.0.3", + "jest": "^29.7.0", + "mwts": "^1.3.0", + "mwtsc": "^1.7.2", + "ts-jest": "^29.1.2", + "typescript": "~5.4.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "scripts": { + "start": "NODE_ENV=production node ./bootstrap.js", + "dev": "cross-env && cross-env NODE_ENV=local TS_NODE_TYPE_CHECK=false TS_NODE_TRANSPILE_ONLY=true midway-bin dev --ts", + "cov": "midway-bin cov --ts", + "lint": "mwts check", + "lint:fix": "mwts fix", + "ci": "npm run cov", + "build": "midway-bin build -c", + "pm2:start": "pm2 start ./bootstrap.js -i max --name cool-admin", + "pm2:stop": "pm2 stop cool-admin & pm2 delete cool-admin" + }, + "midway-bin-clean": [ + ".vscode/.tsbuildinfo", + "dist" + ], + "repository": { + "type": "git", + "url": "https://cool-js.com" + }, + "author": "COOL", + "license": "MIT" +} diff --git a/public/css/welcome.css b/public/css/welcome.css new file mode 100644 index 0000000..c838986 --- /dev/null +++ b/public/css/welcome.css @@ -0,0 +1,89 @@ +body { + display: flex; + height: 100vh; + justify-content: center; + align-items: center; + text-align: center; + background: #222; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.footer-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + color: #6ee1f5; + padding: 10px 0 20px 0; + text-align: center; + opacity: 0; /* 开始时隐藏 */ + animation: fadeIn 5s forwards; /* 应用动画 */ +} + +.link { + color: #6ee1f5; +} + +.reveal { + position: relative; + display: flex; + color: #6ee1f5; + font-size: 2em; + font-family: Raleway, sans-serif; + letter-spacing: 3px; + text-transform: uppercase; + white-space: pre; +} +.reveal span { + opacity: 0; + transform: scale(0); + animation: fadeIn 2.4s forwards; +} +.reveal::before, .reveal::after { + position: absolute; + content: ""; + top: 0; + bottom: 0; + width: 2px; + height: 100%; + background: white; + opacity: 0; + transform: scale(0); +} +.reveal::before { + left: 50%; + animation: slideLeft 1.5s cubic-bezier(0.7, -0.6, 0.3, 1.5) forwards; +} +.reveal::after { + right: 50%; + animation: slideRight 1.5s cubic-bezier(0.7, -0.6, 0.3, 1.5) forwards; +} + +@keyframes fadeIn { + to { + opacity: 1; + transform: scale(1); + } +} +@keyframes slideLeft { + to { + left: -6%; + opacity: 1; + transform: scale(0.9); + } +} +@keyframes slideRight { + to { + right: -6%; + opacity: 1; + transform: scale(0.9); + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..a0c3086 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/js/welcome.js b/public/js/welcome.js new file mode 100644 index 0000000..05054e8 --- /dev/null +++ b/public/js/welcome.js @@ -0,0 +1,14 @@ +const duration = 0.8; +const delay = 0.3; +// eslint-disable-next-line no-undef +const revealText = document.querySelector('.reveal'); +const letters = revealText.textContent.split(''); +revealText.textContent = ''; +const middle = letters.filter(e => e !== ' ').length / 2; +letters.forEach((letter, i) => { + // eslint-disable-next-line no-undef + const span = document.createElement('span'); + span.textContent = letter; + span.style.animationDelay = `${delay + Math.abs(i - middle) * 0.1}s`; + revealText.append(span); +}); diff --git a/src/comm/ipipfree.ipdb b/src/comm/ipipfree.ipdb new file mode 100644 index 0000000..911e1ac Binary files /dev/null and b/src/comm/ipipfree.ipdb differ diff --git a/src/comm/utils.ts b/src/comm/utils.ts new file mode 100644 index 0000000..5591408 --- /dev/null +++ b/src/comm/utils.ts @@ -0,0 +1,161 @@ +import { Inject, Provide } from '@midwayjs/decorator'; +import { Context } from '@midwayjs/koa'; +import * as ipdb from 'ipip-ipdb'; +import * as _ from 'lodash'; +import * as moment from 'moment'; + +/** + * 帮助类 + */ +@Provide() +export class Utils { + @Inject() + baseDir; + + /** + * 获得请求IP + */ + async getReqIP(ctx: Context) { + const req = ctx.req; + return ( + req.headers['x-forwarded-for'] || + req.socket.remoteAddress.replace('::ffff:', '') + ); + } + + /** + * 根据IP获得请求地址 + * @param ip 为空时则为当前请求的IP地址 + */ + async getIpAddr(ctx: Context, ip?: string | string[]) { + try { + if (!ip) { + ip = await this.getReqIP(ctx); + } + const bst = new ipdb.BaseStation(`${this.baseDir}/comm/ipipfree.ipdb`); + const result = bst.findInfo(ip, 'CN'); + const addArr: any = []; + if (result) { + addArr.push(result.countryName); + addArr.push(result.regionName); + addArr.push(result.cityName); + return _.uniq(addArr).join(''); + } + } catch (err) { + return '无法获取地址信息'; + } + } + + /** + * 去除对象的空值属性 + * @param obj + */ + async removeEmptyP(obj) { + Object.keys(obj).forEach(key => { + if (obj[key] === null || obj[key] === '' || obj[key] === 'undefined') { + delete obj[key]; + } + }); + } + + /** + * 线程阻塞毫秒数 + * @param ms + */ + sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * 获得最近几天的日期集合 + * @param recently + */ + getRecentlyDates(recently, format = 'YYYY-MM-DD') { + moment.locale('zh-cn'); + const dates = []; + for (let i = 0; i < recently; i++) { + dates.push(moment().subtract(i, 'days').format(format)); + } + return dates.reverse(); + } + /** + * 获得最近几个月的月数 + * @param recently + */ + getRecentlyMonths(recently, format = 'YYYY-MM') { + moment.locale('zh-cn'); + const dates = []; + const date = moment(Date.now()).format('YYYY-MM'); + for (let i = 0; i < recently; i++) { + dates.push(moment(date).subtract(i, 'months').format(format)); + } + return dates.reverse(); + } + + /** + * 根据开始和结束时间,获得时间段内的日期集合 + * @param start + * @param end + */ + getBetweenDays(start, end, format = 'YYYY-MM-DD') { + moment.locale('zh-cn'); + const dates = []; + const startTime = moment(start).format(format); + const endTime = moment(end).format(format); + const days = moment(endTime).diff(moment(startTime), 'days'); + for (let i = 0; i <= days; i++) { + dates.push(moment(startTime).add(i, 'days').format(format)); + } + return dates; + } + + /** + * 根据开始和结束时间,获得时间段内的月份集合 + * @param start + * @param end + */ + getBetweenMonths(start, end, format = 'YYYY-MM') { + moment.locale('zh-cn'); + const dates = []; + const startTime = moment(start).format(format); + const endTime = moment(end).format(format); + const months = moment(endTime).diff(moment(startTime), 'months'); + for (let i = 0; i <= months; i++) { + dates.push(moment(startTime).add(i, 'months').format(format)); + } + return dates; + } + + /** + * 根据开始和结束时间,获得时间段内的小时集合 + * @param start + * @param end + */ + getBetweenHours(start, end, format = 'YYYY-MM-DD HH') { + moment.locale('zh-cn'); + const dates = []; + const startTime = moment(start).format(format); + const endTime = moment(end).format(format); + const hours = moment(endTime).diff(moment(startTime), 'hours'); + for (let i = 0; i <= hours; i++) { + dates.push(moment(startTime).add(i, 'hours').format(format)); + } + return dates; + } + + /** + * 字段转驼峰法 + * @param obj + * @returns + */ + toCamelCase(obj) { + let camelCaseObject = {}; + for (let i in obj) { + let camelCase = i.replace(/([-_][a-z])/gi, $1 => { + return $1.toUpperCase().replace('-', '').replace('_', ''); + }); + camelCaseObject[camelCase] = obj[i]; + } + return camelCaseObject; + } +} diff --git a/src/config/config.default.ts b/src/config/config.default.ts new file mode 100644 index 0000000..fdbfc31 --- /dev/null +++ b/src/config/config.default.ts @@ -0,0 +1,72 @@ +import { CoolConfig } from '@cool-midway/core'; +import { MidwayConfig } from '@midwayjs/core'; +import { CoolCacheStore } from '@cool-midway/core'; + +// redis缓存 +// import { redisStore } from 'cache-manager-ioredis-yet'; + +export default { + // use for cookie sign key, should change to your own and keep security + keys: 'cool-admin-keys-xxxxxx', + koa: { + port: 8001, + }, + // 模板渲染 + view: { + mapping: { + '.html': 'ejs', + }, + }, + // 静态文件配置 + staticFile: { + buffer: true, + }, + // 文件上传 + upload: { + fileSize: '200mb', + whitelist: null, + }, + // 缓存 可切换成其他缓存如:redis http://www.midwayjs.org/docs/extensions/caching + cacheManager: { + clients: { + default: { + store: CoolCacheStore, + options: { + path: 'cache', + ttl: 0, + }, + }, + }, + }, + // cacheManager: { + // clients: { + // default: { + // store: redisStore, + // options: { + // port: 6379, + // host: '127.0.0.1', + // password: '', + // ttl: 0, + // db: 0, + // }, + // }, + // }, + // }, + cool: { + redis: { + host: '127.0.0.1', + password: '', + port: 6379, + db: 10, + }, + // 已经插件化,本地文件上传查看 plugin/config.ts,其他云存储查看对应插件的使用 + file: {}, + // crud配置 + crud: { + // 插入模式,save不会校验字段(允许传入不存在的字段),insert会校验字段 + upsert: 'save', + // 软删除 + softDelete: true, + }, + } as CoolConfig, +} as MidwayConfig; diff --git a/src/config/config.local.ts b/src/config/config.local.ts new file mode 100644 index 0000000..7b6c0e2 --- /dev/null +++ b/src/config/config.local.ts @@ -0,0 +1,38 @@ +import { CoolConfig } from '@cool-midway/core'; +import { MidwayConfig } from '@midwayjs/core'; + +/** + * 本地开发 npm run dev 读取的配置文件 + */ +export default { + typeorm: { + dataSource: { + default: { + type: 'mysql', + host: '127.0.0.1', + port: 3306, + username: 'root', + password: '123456', + database: 'mall', + // 自动建表 注意:线上部署的时候不要使用,有可能导致数据丢失 + synchronize: true, + // 打印日志 + logging: false, + // 字符集 + charset: 'utf8mb4', + // 是否开启缓存 + cache: true, + // 实体路径 + entities: ['**/modules/*/entity'], + }, + }, + }, + cool: { + // 实体与路径,跟生成代码、前端请求、swagger文档相关 注意:线上不建议开启,以免暴露敏感信息 + eps: true, + // 是否自动导入模块数据库 + initDB: true, + // 是否自动导入模块菜单 + initMenu: true, + } as CoolConfig, +} as MidwayConfig; diff --git a/src/config/config.prod.ts b/src/config/config.prod.ts new file mode 100644 index 0000000..8cf3e66 --- /dev/null +++ b/src/config/config.prod.ts @@ -0,0 +1,50 @@ +import { CoolConfig } from '@cool-midway/core'; +import { MidwayConfig } from '@midwayjs/core'; + +import { createAdapter } from '@socket.io/redis-adapter'; +import Redis from 'ioredis'; + +const redis = { + host: '127.0.0.1', + port: 6379, + password: '', + db: 0, +}; +const pubClient = new Redis(redis); +const subClient = pubClient.duplicate(); + +/** + * 本地开发 npm run prod 读取的配置文件 + */ +export default { + socketIO: { + upgrades: ['websocket'], // 可升级的协议 + adapter: createAdapter(pubClient, subClient), + }, + typeorm: { + dataSource: { + default: { + type: 'mysql', + host: '127.0.0.1', + port: 3306, + username: 'root', + password: '123456', + database: 'mall', + // 自动建表 注意:线上部署的时候不要使用,有可能导致数据丢失 + synchronize: false, + // 打印日志 + logging: false, + // 字符集 + charset: 'utf8mb4', + // 是否开启缓存 + cache: true, + // 实体路径 + entities: ['**/modules/*/entity'], + }, + }, + }, + cool: { + // 是否自动导入数据库,生产环境不建议开,用本地的数据库手动初始化 + initDB: false, + } as CoolConfig, +} as MidwayConfig; diff --git a/src/configuration.ts b/src/configuration.ts new file mode 100644 index 0000000..c5a62ae --- /dev/null +++ b/src/configuration.ts @@ -0,0 +1,63 @@ +import * as orm from '@midwayjs/typeorm'; +import { Configuration, App, Inject } from '@midwayjs/decorator'; +import * as koa from '@midwayjs/koa'; +import * as validate from '@midwayjs/validate'; +import * as info from '@midwayjs/info'; +import { join } from 'path'; +import * as view from '@midwayjs/view-ejs'; +import * as staticFile from '@midwayjs/static-file'; +import * as cron from '@midwayjs/cron'; +// import * as crossDomain from '@midwayjs/cross-domain'; +import * as cool from '@cool-midway/core'; +import { ILogger } from '@midwayjs/logger'; +import * as upload from '@midwayjs/upload'; +import * as socketio from '@midwayjs/socketio'; +import { IMidwayApplication } from '@midwayjs/core'; +// import * as swagger from '@midwayjs/swagger'; +// import * as rpc from '@cool-midway/rpc'; +import * as task from '@cool-midway/task'; + +@Configuration({ + imports: [ + // https://koajs.com/ + koa, + // 是否开启跨域(注:顺序不能乱放!!!) http://www.midwayjs.org/docs/extensions/cross_domain + // crossDomain, + // 模板渲染 https://midwayjs.org/docs/extensions/render + view, + // 静态文件托管 https://midwayjs.org/docs/extensions/static_file + staticFile, + // orm https://midwayjs.org/docs/extensions/orm + orm, + // 参数验证 https://midwayjs.org/docs/extensions/validate + validate, + // 本地任务 http://www.midwayjs.org/docs/extensions/cron + cron, + // 文件上传 + upload, + // cool-admin 官方组件 https://cool-js.com + cool, + // rpc 微服务 远程调用 + // rpc, + // 任务与队列 + task, + // swagger 文档 http://www.midwayjs.org/docs/extensions/swagger + // swagger, + // 即时通讯 + socketio, + { + component: info, + enabledEnvironment: ['local'], + }, + ], + importConfigs: [join(__dirname, './config')], +}) +export class ContainerLifeCycle { + @App() + app: IMidwayApplication; + + @Inject() + logger: ILogger; + + async onReady() {} +} diff --git a/src/interface.ts b/src/interface.ts new file mode 100644 index 0000000..13daae8 --- /dev/null +++ b/src/interface.ts @@ -0,0 +1,6 @@ +/** + * @description User-Service parameters + */ +export interface IUserOptions { + uid: number; +} diff --git a/src/modules/app/config.ts b/src/modules/app/config.ts new file mode 100644 index 0000000..d0eda0e --- /dev/null +++ b/src/modules/app/config.ts @@ -0,0 +1,19 @@ +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 模块配置 + */ +export default () => { + return { + // 模块名称 + name: '应用管理', + // 模块描述 + description: '投诉举报、意见反馈、更新升级、套餐设置等', + // 中间件,只对本模块有效 + middlewares: [], + // 中间件,全局有效 + globalMiddlewares: [], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 0, + } as ModuleConfig; +}; diff --git a/src/modules/app/controller/admin/complain.ts b/src/modules/app/controller/admin/complain.ts new file mode 100644 index 0000000..7098936 --- /dev/null +++ b/src/modules/app/controller/admin/complain.ts @@ -0,0 +1,32 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { AppComplainEntity } from '../../entity/complain'; +import { BaseSysUserEntity } from '../../../base/entity/sys/user'; +import { AppComplainService } from '../../service/complain'; +import { UserInfoEntity } from '../../../user/entity/info'; + +/** + * 意见反馈 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: AppComplainEntity, + service: AppComplainService, + pageQueryOp: { + keyWordLikeFields: ['a.contact', 'b.nickName', 'c.name'], + select: ['a.*', 'b.nickName', 'b.avatarUrl', 'c.name as handlerName'], + fieldEq: ['a.status', 'a.type'], + join: [ + { + entity: UserInfoEntity, + alias: 'b', + condition: 'a.userId = b.id', + }, + { + entity: BaseSysUserEntity, + alias: 'c', + condition: 'a.handlerId = c.id', + }, + ], + }, +}) +export class AdminAppComplainController extends BaseController {} diff --git a/src/modules/app/controller/admin/feedback.ts b/src/modules/app/controller/admin/feedback.ts new file mode 100644 index 0000000..f15af95 --- /dev/null +++ b/src/modules/app/controller/admin/feedback.ts @@ -0,0 +1,32 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { AppFeedbackEntity } from '../../entity/feedback'; +import { UserInfoEntity } from '../../../user/entity/info'; +import { BaseSysUserEntity } from '../../../base/entity/sys/user'; +import { AppFeedbackService } from '../../service/feedback'; + +/** + * 意见反馈 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: AppFeedbackEntity, + service: AppFeedbackService, + pageQueryOp: { + keyWordLikeFields: ['a.contact', 'b.nickName', 'c.name'], + select: ['a.*', 'b.nickName', 'b.avatarUrl', 'c.name as handlerName'], + fieldEq: ['a.status'], + join: [ + { + entity: UserInfoEntity, + alias: 'b', + condition: 'a.userId = b.id', + }, + { + entity: BaseSysUserEntity, + alias: 'c', + condition: 'a.handlerId = c.id', + }, + ], + }, +}) +export class AdminAppFeedbackController extends BaseController {} diff --git a/src/modules/app/controller/admin/goods.ts b/src/modules/app/controller/admin/goods.ts new file mode 100644 index 0000000..f147b42 --- /dev/null +++ b/src/modules/app/controller/admin/goods.ts @@ -0,0 +1,15 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { AppGoodsEntity } from '../../entity/goods'; + +/** + * 套餐 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: AppGoodsEntity, + pageQueryOp: { + keyWordLikeFields: ['a.title'], + fieldEq: ['a.status', 'a.type'], + }, +}) +export class AdminAppGoodsController extends BaseController {} diff --git a/src/modules/app/controller/admin/version.ts b/src/modules/app/controller/admin/version.ts new file mode 100644 index 0000000..9799680 --- /dev/null +++ b/src/modules/app/controller/admin/version.ts @@ -0,0 +1,17 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { AppVersionEntity } from '../../entity/version'; +import { AppVersionService } from '../../service/version'; + +/** + * 版本 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: AppVersionEntity, + service: AppVersionService, + pageQueryOp: { + keyWordLikeFields: ['a.name', 'a.version'], + fieldEq: ['a.status', 'a.type'], + }, +}) +export class AdminAppVersionController extends BaseController {} diff --git a/src/modules/app/controller/app/complain.ts b/src/modules/app/controller/app/complain.ts new file mode 100644 index 0000000..aeaaa33 --- /dev/null +++ b/src/modules/app/controller/app/complain.ts @@ -0,0 +1,38 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { Body, Inject, Post } from '@midwayjs/core'; +import { AppComplainService } from '../../service/complain'; +import { AppComplainEntity } from '../../entity/complain'; + +/** + * 意见反馈 + */ +@CoolController({ + api: ['page', 'info'], + entity: AppComplainEntity, + insertParam: ctx => { + return { + userId: ctx.user.id, + }; + }, + pageQueryOp: { + fieldEq: ['a.type'], + where: ctx => { + const userId = ctx.user.id; + return [['a.userId = :userId', { userId }]]; + }, + }, +}) +export class AppAppComplainController extends BaseController { + @Inject() + appComplainService: AppComplainService; + + @Inject() + ctx; + + @Post('/submit', { summary: '提交投诉举报' }) + async submit(@Body() info) { + info.userId = this.ctx.user.id; + await this.appComplainService.submit(info); + return this.ok(); + } +} diff --git a/src/modules/app/controller/app/feedback.ts b/src/modules/app/controller/app/feedback.ts new file mode 100644 index 0000000..17f4805 --- /dev/null +++ b/src/modules/app/controller/app/feedback.ts @@ -0,0 +1,38 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { Body, Inject, Post } from '@midwayjs/core'; +import { AppFeedbackService } from '../../service/feedback'; +import { AppFeedbackEntity } from '../../entity/feedback'; + +/** + * 意见反馈 + */ +@CoolController({ + api: ['page', 'info'], + entity: AppFeedbackEntity, + insertParam: ctx => { + return { + userId: ctx.user.id, + }; + }, + pageQueryOp: { + fieldEq: ['a.type'], + where: ctx => { + const userId = ctx.user.id; + return [['a.userId = :userId', { userId }]]; + }, + }, +}) +export class AppAppFeedbackController extends BaseController { + @Inject() + appFeedbackService: AppFeedbackService; + + @Inject() + ctx; + + @Post('/submit', { summary: '提交意见反馈' }) + async submit(@Body() info) { + info.userId = this.ctx.user.id; + await this.appFeedbackService.submit(info); + return this.ok(); + } +} diff --git a/src/modules/app/controller/app/goods.ts b/src/modules/app/controller/app/goods.ts new file mode 100644 index 0000000..d0dd0fb --- /dev/null +++ b/src/modules/app/controller/app/goods.ts @@ -0,0 +1,19 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { AppGoodsEntity } from '../../entity/goods'; + +/** + * 套餐 + */ +@CoolController({ + api: ['list'], + entity: AppGoodsEntity, + listQueryOp: { + addOrderBy: { + sort: 'ASC', + }, + where: () => { + return [['a.status = :status', { status: 1 }]]; + }, + }, +}) +export class AppAppGoodsController extends BaseController {} diff --git a/src/modules/app/controller/app/version.ts b/src/modules/app/controller/app/version.ts new file mode 100644 index 0000000..119476c --- /dev/null +++ b/src/modules/app/controller/app/version.ts @@ -0,0 +1,30 @@ +import { + CoolController, + BaseController, + CoolUrlTag, + CoolTag, + TagTypes, +} from '@cool-midway/core'; +import { Get, Inject, Query } from '@midwayjs/core'; +import { AppVersionService } from '../../service/version'; +import { AppVersionEntity } from '../../entity/version'; + +/** + * 版本 + */ +@CoolUrlTag() +@CoolController({ + api: [], + entity: AppVersionEntity, +}) +export class AppAppVersionController extends BaseController { + @Inject() + appVersionService: AppVersionService; + + @CoolTag(TagTypes.IGNORE_TOKEN) + @Get('/check', { summary: '检查版本' }) + async check(@Query('version') version: string, @Query('type') type = 0) { + const result = await this.appVersionService.check(version, type); + return this.ok(result); + } +} diff --git a/src/modules/app/db.json b/src/modules/app/db.json new file mode 100644 index 0000000..53fde8a --- /dev/null +++ b/src/modules/app/db.json @@ -0,0 +1,124 @@ +{ + "dict_type": [ + { + "name": "升级类型", + "key": "upgradeType", + "@childDatas": { + "dict_info": [ + { + "typeId": "@id", + "name": "安卓", + "orderNum": 1, + "remark": null, + "parentId": null, + "value": "0" + }, + { + "typeId": "@id", + "name": "IOS", + "orderNum": 1, + "remark": null, + "parentId": null, + "value": "1" + } + ] + } + }, + { + "name": "投诉类型", + "key": "complainType", + "@childDatas": { + "dict_info": [ + { + "typeId": "@id", + "name": "崩溃与错误", + "orderNum": 1, + "remark": null, + "parentId": null, + "value": "0" + }, + { + "typeId": "@id", + "name": "支付问题", + "orderNum": 1, + "remark": null, + "parentId": null, + "value": "1" + }, + { + "typeId": "@id", + "name": "体验不佳", + "orderNum": 1, + "remark": null, + "parentId": null, + "value": "2" + }, + { + "typeId": "@id", + "name": "功能缺失", + "orderNum": 1, + "remark": null, + "parentId": null, + "value": "3" + }, + { + "typeId": "@id", + "name": "其他", + "orderNum": 1, + "remark": null, + "parentId": null, + "value": "4" + } + ] + } + }, + { + "name": "反馈类型", + "key": "feedbackType", + "@childDatas": { + "dict_info": [ + { + "typeId": "@id", + "name": "崩溃与错误", + "value": "0", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "typeId": "@id", + "name": "支付问题", + "value": "1", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "typeId": "@id", + "name": "体验不佳", + "value": "2", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "typeId": "@id", + "name": "功能缺失", + "value": "3", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "typeId": "@id", + "name": "其他", + "value": "-1", + "orderNum": 1, + "remark": null, + "parentId": null + } + ] + } + } + ] +} \ No newline at end of file diff --git a/src/modules/app/entity/complain.ts b/src/modules/app/entity/complain.ts new file mode 100644 index 0000000..31baa59 --- /dev/null +++ b/src/modules/app/entity/complain.ts @@ -0,0 +1,33 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 举报投诉 + */ +@Entity('app_complain') +export class AppComplainEntity extends BaseEntity { + @Index() + @Column({ comment: '用户ID' }) + userId: number; + + @Column({ comment: '类型' }) + type: number; + + @Column({ comment: '联系方式' }) + contact: string; + + @Column({ comment: '内容' }) + content: string; + + @Column({ comment: '图片', type: 'json', nullable: true }) + images: string[]; + + @Column({ comment: '状态 0-未处理 1-已处理', default: 0 }) + status: number; + + @Column({ comment: '处理人ID', nullable: true }) + handlerId: number; + + @Column({ comment: '备注', type: 'text', nullable: true }) + remark: string; +} diff --git a/src/modules/app/entity/feedback.ts b/src/modules/app/entity/feedback.ts new file mode 100644 index 0000000..5c5be76 --- /dev/null +++ b/src/modules/app/entity/feedback.ts @@ -0,0 +1,33 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 意见反馈 + */ +@Entity('app_feedback') +export class AppFeedbackEntity extends BaseEntity { + @Index() + @Column({ comment: '用户ID' }) + userId: number; + + @Column({ comment: '联系方式' }) + contact: string; + + @Column({ comment: '类型' }) + type: number; + + @Column({ comment: '内容' }) + content: string; + + @Column({ comment: '图片', type: 'json', nullable: true }) + images: string[]; + + @Column({ comment: '状态 0-未处理 1-已处理', default: 0 }) + status: number; + + @Column({ comment: '处理人ID', nullable: true }) + handlerId: number; + + @Column({ comment: '备注', type: 'text', nullable: true }) + remark: string; +} diff --git a/src/modules/app/entity/goods.ts b/src/modules/app/entity/goods.ts new file mode 100644 index 0000000..1ad6287 --- /dev/null +++ b/src/modules/app/entity/goods.ts @@ -0,0 +1,48 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity } from 'typeorm'; + +/** + * 套餐 + */ +@Entity('app_goods') +export class AppGoodsEntity extends BaseEntity { + @Column({ comment: '标题' }) + title: string; + + @Column({ + comment: '价格', + type: 'decimal', + precision: 12, + scale: 2, + }) + price: number; + + @Column({ + comment: '原价', + type: 'decimal', + precision: 12, + scale: 2, + }) + originalPrice: number; + + @Column({ comment: '描述', type: 'text', nullable: true }) + description: string; + + @Column({ comment: '状态 0-禁用 1-启用', default: 1 }) + status: number; + + @Column({ comment: '排序', default: 0 }) + sort: number; + + @Column({ comment: '类型 0-天 1-月 2-年 3-永久', default: 0 }) + type: number; + + @Column({ comment: '时长', default: 1 }) + duration: number; + + @Column({ comment: '标签', nullable: true }) + tag: string; + + @Column({ comment: '标签颜色', default: '#26A7FD' }) + tagColor: string; +} diff --git a/src/modules/app/entity/version.ts b/src/modules/app/entity/version.ts new file mode 100644 index 0000000..fa8c39a --- /dev/null +++ b/src/modules/app/entity/version.ts @@ -0,0 +1,32 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity } from 'typeorm'; + +/** + * 应用版本 + */ +@Entity('app_version') +export class AppVersionEntity extends BaseEntity { + @Column({ comment: '名称' }) + name: string; + + @Column({ comment: '版本号' }) + version: string; + + @Column({ comment: '类型', default: 0 }) + type: number; + + @Column({ comment: '下载地址' }) + url: string; + + @Column({ comment: '强制更新 0-否 1-是', default: 0 }) + forceUpdate: number; + + @Column({ comment: '状态 0-禁用 1-启用', default: 1 }) + status: number; + + @Column({ comment: '热更新 0-否 1-是', default: 0 }) + hotUpdate: number; + + @Column({ comment: '描述', type: 'text' }) + description: string; +} diff --git a/src/modules/app/menu.json b/src/modules/app/menu.json new file mode 100644 index 0000000..2906595 --- /dev/null +++ b/src/modules/app/menu.json @@ -0,0 +1,270 @@ +[ + { + "name": "应用管理", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-app", + "orderNum": 4, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "版本管理", + "router": "/app/version", + "perms": null, + "type": 1, + "icon": "icon-tag", + "orderNum": 0, + "viewPath": "modules/app/views/version.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "删除", + "router": null, + "perms": "app:version:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "app:version:update,app:version:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "单个信息", + "router": null, + "perms": "app:version:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "列表查询", + "router": null, + "perms": "app:version:list", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "分页查询", + "router": null, + "perms": "app:version:page", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "新增", + "router": null, + "perms": "app:version:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "意见反馈", + "router": "/app/feedback", + "perms": null, + "type": 1, + "icon": "icon-info", + "orderNum": 0, + "viewPath": "modules/app/views/feedback.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "删除", + "router": null, + "perms": "app:feedback:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "app:feedback:update,app:feedback:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "单个信息", + "router": null, + "perms": "app:feedback:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "列表查询", + "router": null, + "perms": "app:feedback:list", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "分页查询", + "router": null, + "perms": "app:feedback:page", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "新增", + "router": null, + "perms": "app:feedback:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "投诉举报", + "router": "/app/complain", + "perms": null, + "type": 1, + "icon": "icon-new", + "orderNum": 0, + "viewPath": "modules/app/views/complain.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "删除", + "router": null, + "perms": "app:complain:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "app:complain:update,app:complain:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "单个信息", + "router": null, + "perms": "app:complain:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "列表查询", + "router": null, + "perms": "app:complain:list", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "分页查询", + "router": null, + "perms": "app:complain:page", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "新增", + "router": null, + "perms": "app:complain:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + } + ] + } +] \ No newline at end of file diff --git a/src/modules/app/package.json b/src/modules/app/package.json new file mode 100644 index 0000000..726d311 --- /dev/null +++ b/src/modules/app/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "semver": "^7.5.4" + } +} \ No newline at end of file diff --git a/src/modules/app/service/complain.ts b/src/modules/app/service/complain.ts new file mode 100644 index 0000000..055b679 --- /dev/null +++ b/src/modules/app/service/complain.ts @@ -0,0 +1,36 @@ +import { Inject, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { AppComplainEntity } from '../entity/complain'; + +/** + * 意见反馈 + */ +@Provide() +export class AppComplainService extends BaseService { + @InjectEntityModel(AppComplainEntity) + appComplainEntity: Repository; + + @Inject() + ctx; + + /** + * 提交 + * @param info + */ + async submit(info: AppComplainEntity) { + await this.appComplainEntity.insert(info); + } + + /** + * 更新 + * @param param + */ + async update(param: AppComplainEntity) { + if (param.status == 1) { + param.handlerId = this.ctx.admin.userId; + } + await super.update(param); + } +} diff --git a/src/modules/app/service/feedback.ts b/src/modules/app/service/feedback.ts new file mode 100644 index 0000000..460bb11 --- /dev/null +++ b/src/modules/app/service/feedback.ts @@ -0,0 +1,36 @@ +import { Inject, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { AppFeedbackEntity } from '../entity/feedback'; + +/** + * 意见反馈 + */ +@Provide() +export class AppFeedbackService extends BaseService { + @InjectEntityModel(AppFeedbackEntity) + appFeedbackEntity: Repository; + + @Inject() + ctx; + + /** + * 提交 + * @param info + */ + async submit(info: AppFeedbackEntity) { + await this.appFeedbackEntity.insert(info); + } + + /** + * 更新 + * @param param + */ + async update(param: AppFeedbackEntity) { + if (param.status == 1) { + param.handlerId = this.ctx.admin.userId; + } + await super.update(param); + } +} diff --git a/src/modules/app/service/version.ts b/src/modules/app/service/version.ts new file mode 100644 index 0000000..06786ec --- /dev/null +++ b/src/modules/app/service/version.ts @@ -0,0 +1,45 @@ +import { Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Not, Repository } from 'typeorm'; +import { AppVersionEntity } from '../entity/version'; +import * as semver from 'semver'; + +/** + * 应用版本 + */ +@Provide() +export class AppVersionService extends BaseService { + @InjectEntityModel(AppVersionEntity) + appVersionEntity: Repository; + + /** + * 检查更新 + * @param version + */ + async check(version: string, type = 0) { + const info = await this.appVersionEntity.findOneBy({ type, status: 1 }); + if (info && semver.gt(info.version, version)) { + return info; + } + return; + } + + /** + * 修改之后 + * @param data + * @param type + */ + async modifyAfter(data: any, type: 'add' | 'update' | 'delete') { + if (type == 'add' || type == 'update') { + const info = await this.appVersionEntity.findOneBy({ id: data.id }); + if (info.status == 1) { + // 将其他的版本设置为禁用 + await this.appVersionEntity.update( + { type: info.type, id: Not(info.id) }, + { status: 0 } + ); + } + } + } +} diff --git a/src/modules/base/config.ts b/src/modules/base/config.ts new file mode 100644 index 0000000..4c7bf0b --- /dev/null +++ b/src/modules/base/config.ts @@ -0,0 +1,35 @@ +import { BaseLogMiddleware } from './middleware/log'; +import { BaseAuthorityMiddleware } from './middleware/authority'; +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 模块的配置 + */ +export default () => { + return { + // 模块名称 + name: '权限管理', + // 模块描述 + description: '基础的权限管理功能,包括登录,权限校验', + // 中间件 + globalMiddlewares: [BaseAuthorityMiddleware, BaseLogMiddleware], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 10, + // app参数配置允许读取的key + allowKeys: ['privacyPolicy', 'userAgreement'], + // jwt 生成解密token的 + jwt: { + // 单点登录 + sso: false, + // 注意: 最好重新修改,防止破解 + secret: 'f7ad2d70e28a11eeb7e2214c12a8b138', + // token + token: { + // 2小时过期,需要用刷新token + expire: 2 * 3600, + // 15天内,如果没操作过就需要重新登录 + refreshExpire: 24 * 3600 * 15, + }, + }, + } as ModuleConfig; +}; diff --git a/src/modules/base/controller/admin/comm.ts b/src/modules/base/controller/admin/comm.ts new file mode 100644 index 0000000..3c41276 --- /dev/null +++ b/src/modules/base/controller/admin/comm.ts @@ -0,0 +1,86 @@ +import { Provide, Inject, Get, Post, Body, ALL } from '@midwayjs/decorator'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { BaseSysUserEntity } from '../../entity/sys/user'; +import { BaseSysLoginService } from '../../service/sys/login'; +import { BaseSysPermsService } from '../../service/sys/perms'; +import { BaseSysUserService } from '../../service/sys/user'; +import { Context } from '@midwayjs/koa'; +import { PluginService } from '../../../plugin/service/info'; + +/** + * Base 通用接口 一般写不需要权限过滤的接口 + */ +@Provide() +@CoolController() +export class BaseCommController extends BaseController { + @Inject() + baseSysUserService: BaseSysUserService; + + @Inject() + baseSysPermsService: BaseSysPermsService; + + @Inject() + baseSysLoginService: BaseSysLoginService; + + @Inject() + ctx: Context; + + @Inject() + pluginService: PluginService; + + /** + * 获得个人信息 + */ + @Get('/person', { summary: '个人信息' }) + async person() { + return this.ok( + await this.baseSysUserService.person(this.ctx.admin?.userId) + ); + } + + /** + * 修改个人信息 + */ + @Post('/personUpdate', { summary: '修改个人信息' }) + async personUpdate(@Body(ALL) user: BaseSysUserEntity) { + await this.baseSysUserService.personUpdate(user); + return this.ok(); + } + + /** + * 权限菜单 + */ + @Get('/permmenu', { summary: '权限与菜单' }) + async permmenu() { + return this.ok( + await this.baseSysPermsService.permmenu(this.ctx.admin.roleIds) + ); + } + + /** + * 文件上传 + */ + @Post('/upload', { summary: '文件上传' }) + async upload() { + const file = await this.pluginService.getInstance('upload'); + return this.ok(await file.upload(this.ctx)); + } + + /** + * 文件上传模式,本地或者云存储 + */ + @Get('/uploadMode', { summary: '文件上传模式' }) + async uploadMode() { + const file = await this.pluginService.getInstance('upload'); + return this.ok(await file.getMode()); + } + + /** + * 退出 + */ + @Post('/logout', { summary: '退出' }) + async logout() { + await this.baseSysLoginService.logout(); + return this.ok(); + } +} diff --git a/src/modules/base/controller/admin/open.ts b/src/modules/base/controller/admin/open.ts new file mode 100644 index 0000000..b42785b --- /dev/null +++ b/src/modules/base/controller/admin/open.ts @@ -0,0 +1,99 @@ +import { Provide, Body, Inject, Post, Get, Query } from '@midwayjs/decorator'; +import { + CoolController, + BaseController, + CoolEps, + CoolUrlTag, + CoolTag, + TagTypes, + RESCODE, +} from '@cool-midway/core'; +import { LoginDTO } from '../../dto/login'; +import { BaseSysLoginService } from '../../service/sys/login'; +import { BaseSysParamService } from '../../service/sys/param'; +import { Context } from '@midwayjs/koa'; +import { Validate } from '@midwayjs/validate'; + +/** + * 不需要登录的后台接口 + */ +@Provide() +@CoolController({ description: '开放接口' }) +@CoolUrlTag() +export class BaseOpenController extends BaseController { + @Inject() + baseSysLoginService: BaseSysLoginService; + + @Inject() + baseSysParamService: BaseSysParamService; + + @Inject() + ctx: Context; + + @Inject() + eps: CoolEps; + + /** + * 实体信息与路径 + * @returns + */ + @CoolTag(TagTypes.IGNORE_TOKEN) + @Get('/eps', { summary: '实体信息与路径' }) + public async getEps() { + return this.ok(this.eps.admin); + } + + /** + * 根据配置参数key获得网页内容(富文本) + */ + @CoolTag(TagTypes.IGNORE_TOKEN) + @Get('/html', { summary: '获得网页内容的参数值' }) + async htmlByKey(@Query('key') key: string) { + this.ctx.body = await this.baseSysParamService.htmlByKey(key); + } + + /** + * 登录 + * @param login + */ + @CoolTag(TagTypes.IGNORE_TOKEN) + @Post('/login', { summary: '登录' }) + @Validate() + async login(@Body() login: LoginDTO) { + return this.ok(await this.baseSysLoginService.login(login)); + } + + /** + * 获得验证码 + */ + @CoolTag(TagTypes.IGNORE_TOKEN) + @Get('/captcha', { summary: '验证码' }) + async captcha( + @Query('type') type: string, + @Query('width') width: number, + @Query('height') height: number, + @Query('color') color: string + ) { + return this.ok( + await this.baseSysLoginService.captcha(type, width, height, color) + ); + } + + /** + * 刷新token + */ + @CoolTag(TagTypes.IGNORE_TOKEN) + @Get('/refreshToken', { summary: '刷新token' }) + async refreshToken(@Query('refreshToken') refreshToken: string) { + try { + const token = await this.baseSysLoginService.refreshToken(refreshToken); + return this.ok(token); + } catch (e) { + this.ctx.status = 401; + this.ctx.body = { + code: RESCODE.COMMFAIL, + message: '登录失效~', + }; + } + } +} diff --git a/src/modules/base/controller/admin/sys/department.ts b/src/modules/base/controller/admin/sys/department.ts new file mode 100644 index 0000000..4c2b0b2 --- /dev/null +++ b/src/modules/base/controller/admin/sys/department.ts @@ -0,0 +1,27 @@ +import { ALL, Body, Inject, Post, Provide } from '@midwayjs/decorator'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { BaseSysDepartmentEntity } from '../../../entity/sys/department'; +import { BaseSysDepartmentService } from '../../../service/sys/department'; + +/** + * 部门 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'update', 'list'], + entity: BaseSysDepartmentEntity, + service: BaseSysDepartmentService, +}) +export class BaseDepartmentController extends BaseController { + @Inject() + baseDepartmentService: BaseSysDepartmentService; + + /** + * 部门排序 + */ + @Post('/order', { summary: '排序' }) + async order(@Body(ALL) params: any) { + await this.baseDepartmentService.order(params); + return this.ok(); + } +} diff --git a/src/modules/base/controller/admin/sys/log.ts b/src/modules/base/controller/admin/sys/log.ts new file mode 100644 index 0000000..cde3006 --- /dev/null +++ b/src/modules/base/controller/admin/sys/log.ts @@ -0,0 +1,64 @@ +import { Provide, Post, Inject, Body, Get } from '@midwayjs/decorator'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { BaseSysLogEntity } from '../../../entity/sys/log'; +import { BaseSysUserEntity } from '../../../entity/sys/user'; +import { BaseSysConfService } from '../../../service/sys/conf'; +import { BaseSysLogService } from '../../../service/sys/log'; + +/** + * 系统日志 + */ +@Provide() +@CoolController({ + api: ['page'], + entity: BaseSysLogEntity, + urlTag: { + name: 'a', + url: ['add'], + }, + pageQueryOp: { + keyWordLikeFields: ['b.name', 'a.params', 'a.ipAddr'], + select: ['a.*', 'b.name'], + join: [ + { + entity: BaseSysUserEntity, + alias: 'b', + condition: 'a.userId = b.id', + type: 'leftJoin', + }, + ], + }, +}) +export class BaseSysLogController extends BaseController { + @Inject() + baseSysLogService: BaseSysLogService; + + @Inject() + baseSysConfService: BaseSysConfService; + + /** + * 清空日志 + */ + @Post('/clear', { summary: '清理' }) + public async clear() { + await this.baseSysLogService.clear(true); + return this.ok(); + } + + /** + * 设置日志保存时间 + */ + @Post('/setKeep', { summary: '日志保存时间' }) + public async setKeep(@Body('value') value: number) { + await this.baseSysConfService.updateVaule('logKeep', value); + return this.ok(); + } + + /** + * 获得日志保存时间 + */ + @Get('/getKeep', { summary: '获得日志保存时间' }) + public async getKeep() { + return this.ok(await this.baseSysConfService.getValue('logKeep')); + } +} diff --git a/src/modules/base/controller/admin/sys/menu.ts b/src/modules/base/controller/admin/sys/menu.ts new file mode 100644 index 0000000..fd3f3d4 --- /dev/null +++ b/src/modules/base/controller/admin/sys/menu.ts @@ -0,0 +1,46 @@ +import { Body, Inject, Post, Provide } from '@midwayjs/decorator'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { BaseSysMenuEntity } from '../../../entity/sys/menu'; +import { BaseSysMenuService } from '../../../service/sys/menu'; + +/** + * 菜单 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: BaseSysMenuEntity, + service: BaseSysMenuService, +}) +export class BaseSysMenuController extends BaseController { + @Inject() + baseSysMenuService: BaseSysMenuService; + + @Post('/parse', { summary: '解析' }) + async parse( + @Body('entity') entity: string, + @Body('controller') controller: string, + @Body('module') module: string + ) { + return this.ok( + await this.baseSysMenuService.parse(entity, controller, module) + ); + } + + @Post('/create', { summary: '创建代码' }) + async create(@Body() body) { + await this.baseSysMenuService.create(body); + return this.ok(); + } + + @Post('/export', { summary: '导出' }) + async export(@Body('ids') ids: number[]) { + return this.ok(await this.baseSysMenuService.export(ids)); + } + + @Post('/import', { summary: '导入' }) + async import(@Body('menus') menus: any[]) { + await this.baseSysMenuService.import(menus); + return this.ok(); + } +} diff --git a/src/modules/base/controller/admin/sys/param.ts b/src/modules/base/controller/admin/sys/param.ts new file mode 100644 index 0000000..a6082c7 --- /dev/null +++ b/src/modules/base/controller/admin/sys/param.ts @@ -0,0 +1,34 @@ +import { Get, Inject, Provide, Query } from '@midwayjs/decorator'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { BaseSysParamEntity } from '../../../entity/sys/param'; +import { BaseSysParamService } from '../../../service/sys/param'; +import { Context } from '@midwayjs/koa'; + +/** + * 参数配置 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'page'], + entity: BaseSysParamEntity, + service: BaseSysParamService, + pageQueryOp: { + keyWordLikeFields: ['name', 'keyName'], + fieldEq: ['dataType'], + }, +}) +export class BaseSysParamController extends BaseController { + @Inject() + baseSysParamService: BaseSysParamService; + + @Inject() + ctx: Context; + + /** + * 根据配置参数key获得网页内容(富文本) + */ + @Get('/html', { summary: '获得网页内容的参数值' }) + async htmlByKey(@Query('key') key: string) { + this.ctx.body = await this.baseSysParamService.htmlByKey(key); + } +} diff --git a/src/modules/base/controller/admin/sys/role.ts b/src/modules/base/controller/admin/sys/role.ts new file mode 100644 index 0000000..210dbe6 --- /dev/null +++ b/src/modules/base/controller/admin/sys/role.ts @@ -0,0 +1,38 @@ +import { Provide } from '@midwayjs/decorator'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { Context } from 'vm'; +import { BaseSysRoleEntity } from '../../../entity/sys/role'; +import { BaseSysRoleService } from '../../../service/sys/role'; + +/** + * 系统角色 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: BaseSysRoleEntity, + service: BaseSysRoleService, + // 新增的时候插入当前用户ID + insertParam: async (ctx: Context) => { + return { + userId: ctx.admin.userId, + }; + }, + pageQueryOp: { + keyWordLikeFields: ['a.name', 'a.label'], + where: async (ctx: Context) => { + const { userId, roleIds, username } = ctx.admin; + return [ + // 超级管理员的角色不展示 + ['label != :label', { label: 'admin' }], + // 如果不是超管,只能看到自己新建的或者自己有的角色 + [ + '(userId=:userId or id in (:...roleIds))', + { userId, roleIds }, + username !== 'admin', + ], + ]; + }, + }, +}) +export class BaseSysRoleController extends BaseController {} diff --git a/src/modules/base/controller/admin/sys/user.ts b/src/modules/base/controller/admin/sys/user.ts new file mode 100644 index 0000000..82056de --- /dev/null +++ b/src/modules/base/controller/admin/sys/user.ts @@ -0,0 +1,30 @@ +import { Body, Inject, Post, Provide } from '@midwayjs/decorator'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { BaseSysUserEntity } from '../../../entity/sys/user'; +import { BaseSysUserService } from '../../../service/sys/user'; + +/** + * 系统用户 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: BaseSysUserEntity, + service: BaseSysUserService, +}) +export class BaseSysUserController extends BaseController { + @Inject() + baseSysUserService: BaseSysUserService; + + /** + * 移动部门 + */ + @Post('/move', { summary: '移动部门' }) + async move( + @Body('departmentId') departmentId: number, + @Body('userIds') userIds: [] + ) { + await this.baseSysUserService.move(departmentId, userIds); + return this.ok(); + } +} diff --git a/src/modules/base/controller/app/README.md b/src/modules/base/controller/app/README.md new file mode 100644 index 0000000..df64e76 --- /dev/null +++ b/src/modules/base/controller/app/README.md @@ -0,0 +1 @@ +这里写对外的api接口 \ No newline at end of file diff --git a/src/modules/base/controller/app/comm.ts b/src/modules/base/controller/app/comm.ts new file mode 100644 index 0000000..4c477a3 --- /dev/null +++ b/src/modules/base/controller/app/comm.ts @@ -0,0 +1,72 @@ +import { Provide, Inject, Get, Post, Query, Config } from '@midwayjs/decorator'; +import { + CoolController, + BaseController, + CoolEps, + TagTypes, + CoolUrlTag, + CoolTag, +} from '@cool-midway/core'; +import { Context } from '@midwayjs/koa'; +import { BaseSysParamService } from '../../service/sys/param'; +import { PluginService } from '../../../plugin/service/info'; + +/** + * 不需要登录的后台接口 + */ +@CoolUrlTag() +@Provide() +@CoolController() +export class BaseAppCommController extends BaseController { + @Inject() + pluginService: PluginService; + + @Inject() + ctx: Context; + + @Config('module.base.allowKeys') + allowKeys: string[]; + + @Inject() + eps: CoolEps; + + @Inject() + baseSysParamService: BaseSysParamService; + + @CoolTag(TagTypes.IGNORE_TOKEN) + @Get('/param', { summary: '参数配置' }) + async param(@Query('key') key: string) { + if (!this.allowKeys.includes(key)) { + return this.fail('非法操作'); + } + return this.ok(await this.baseSysParamService.dataByKey(key)); + } + + /** + * 实体信息与路径 + * @returns + */ + @CoolTag(TagTypes.IGNORE_TOKEN) + @Get('/eps', { summary: '实体信息与路径' }) + public async getEps() { + return this.ok(this.eps.app); + } + + /** + * 文件上传 + */ + @Post('/upload', { summary: '文件上传' }) + async upload() { + const file = await this.pluginService.getInstance('upload'); + return this.ok(await file.upload(this.ctx)); + } + + /** + * 文件上传模式,本地或者云存储 + */ + @Get('/uploadMode', { summary: '文件上传模式' }) + async uploadMode() { + const file = await this.pluginService.getInstance('upload'); + return this.ok(await file.getMode()); + } +} diff --git a/src/modules/base/db.json b/src/modules/base/db.json new file mode 100644 index 0000000..ec6263b --- /dev/null +++ b/src/modules/base/db.json @@ -0,0 +1,103 @@ +{ + "base_sys_param": [ + { + "keyName": "orderTimeout", + "name": "订单超时时间", + "data": "120", + "dataType": 0, + "remark": "订单超时未支付,自动关闭。单位:分钟" + }, + { + "keyName": "orderConfirm", + "name": "自动确认收货", + "data": "15", + "dataType": 0, + "remark": "自动确认收货时间,从支付成功起算,单位:天" + }, + { + "keyName": "userAgreement", + "name": "用户协议", + "data": "

用户协议


xxxxxx

", + "dataType": 1, + "remark": null + }, + { + "keyName": "privacyPolicy", + "name": "隐私政策", + "data": "

隐私政策


xxxxxx

", + "dataType": 1, + "remark": null + } + ], + "base_sys_conf": [ + { + "cKey": "logKeep", + "cValue": "31" + }, + { + "cKey": "recycleKeep", + "cValue": "31" + } + ], + "base_sys_department": [ + { + "id": 1, + "name": "COOL", + "parentId": null, + "orderNum": 0 + }, + { + "id": 11, + "name": "开发", + "parentId": 12, + "orderNum": 2 + }, + { + "id": 12, + "name": "测试", + "parentId": 1, + "orderNum": 1 + }, + { + "id": 13, + "name": "游客", + "parentId": 1, + "orderNum": 3 + } + ], + "base_sys_role": [ + { + "id": 1, + "userId": "1", + "name": "超管", + "label": "admin", + "remark": "最高权限的角色", + "relevance": 1, + "menuIdList": "null", + "departmentIdList": "null" + } + ], + "base_sys_user": [ + { + "id": 1, + "departmentId": 1, + "name": "超级管理员", + "username": "admin", + "password": "e10adc3949ba59abbe56e057f20f883e", + "passwordV": 7, + "nickName": "管理员", + "headImg": "https://cool-js.com/admin/headimg.jpg", + "phone": "18000000000", + "email": "team@cool-js.com", + "status": 1, + "remark": "拥有最高权限的用户", + "socketId": null + } + ], + "base_sys_user_role": [ + { + "userId": 1, + "roleId": 1 + } + ] +} \ No newline at end of file diff --git a/src/modules/base/dto/login.ts b/src/modules/base/dto/login.ts new file mode 100644 index 0000000..5b4b30c --- /dev/null +++ b/src/modules/base/dto/login.ts @@ -0,0 +1,21 @@ +import { Rule, RuleType } from '@midwayjs/validate'; +/** + * 登录参数校验 + */ +export class LoginDTO { + // 用户名 + @Rule(RuleType.string().required()) + username: string; + + // 密码 + @Rule(RuleType.string().required()) + password: string; + + // 验证码ID + @Rule(RuleType.string().required()) + captchaId: string; + + // 验证码 + @Rule(RuleType.required()) + verifyCode: number; +} diff --git a/src/modules/base/entity/sys/conf.ts b/src/modules/base/entity/sys/conf.ts new file mode 100644 index 0000000..fcfc5e1 --- /dev/null +++ b/src/modules/base/entity/sys/conf.ts @@ -0,0 +1,15 @@ +import { Column, Index, Entity } from 'typeorm'; +import { BaseEntity } from '@cool-midway/core'; + +/** + * 系统配置 + */ +@Entity('base_sys_conf') +export class BaseSysConfEntity extends BaseEntity { + @Index({ unique: true }) + @Column({ comment: '配置键' }) + cKey: string; + + @Column({ comment: '配置值' }) + cValue: string; +} diff --git a/src/modules/base/entity/sys/department.ts b/src/modules/base/entity/sys/department.ts new file mode 100644 index 0000000..f340ecc --- /dev/null +++ b/src/modules/base/entity/sys/department.ts @@ -0,0 +1,19 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity } from 'typeorm'; + +/** + * 部门 + */ +@Entity('base_sys_department') +export class BaseSysDepartmentEntity extends BaseEntity { + @Column({ comment: '部门名称' }) + name: string; + + @Column({ comment: '上级部门ID', nullable: true }) + parentId: number; + + @Column({ comment: '排序', default: 0 }) + orderNum: number; + // 父菜单名称 + parentName: string; +} diff --git a/src/modules/base/entity/sys/log.ts b/src/modules/base/entity/sys/log.ts new file mode 100644 index 0000000..2ed30a6 --- /dev/null +++ b/src/modules/base/entity/sys/log.ts @@ -0,0 +1,27 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Index, Entity } from 'typeorm'; + +/** + * 系统日志 + */ +@Entity('base_sys_log') +export class BaseSysLogEntity extends BaseEntity { + @Index() + @Column({ comment: '用户ID', nullable: true }) + userId: number; + + @Index() + @Column({ comment: '行为' }) + action: string; + + @Index() + @Column({ comment: 'ip', nullable: true }) + ip: string; + + @Index() + @Column({ comment: 'ip地址', nullable: true, length: 50 }) + ipAddr: string; + + @Column({ comment: '参数', nullable: true, type: 'json' }) + params: string; +} diff --git a/src/modules/base/entity/sys/menu.ts b/src/modules/base/entity/sys/menu.ts new file mode 100644 index 0000000..5636071 --- /dev/null +++ b/src/modules/base/entity/sys/menu.ts @@ -0,0 +1,44 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity } from 'typeorm'; + +/** + * 菜单 + */ +@Entity('base_sys_menu') +export class BaseSysMenuEntity extends BaseEntity { + @Column({ comment: '父菜单ID', nullable: true }) + parentId: number; + + @Column({ comment: '菜单名称' }) + name: string; + + @Column({ comment: '菜单地址', nullable: true }) + router: string; + + @Column({ comment: '权限标识', type: 'text', nullable: true }) + perms: string; + + @Column({ + comment: '类型 0-目录 1-菜单 2-按钮', + default: 0, + }) + type: number; + + @Column({ comment: '图标', nullable: true }) + icon: string; + + @Column({ comment: '排序', default: 0 }) + orderNum: number; + + @Column({ comment: '视图地址', nullable: true }) + viewPath: string; + + @Column({ comment: '路由缓存', default: true }) + keepAlive: boolean; + + // 父菜单名称 + parentName: string; + + @Column({ comment: '是否显示', default: true }) + isShow: boolean; +} diff --git a/src/modules/base/entity/sys/param.ts b/src/modules/base/entity/sys/param.ts new file mode 100644 index 0000000..f147f89 --- /dev/null +++ b/src/modules/base/entity/sys/param.ts @@ -0,0 +1,27 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Index, Entity } from 'typeorm'; + +/** + * 参数配置 + */ +@Entity('base_sys_param') +export class BaseSysParamEntity extends BaseEntity { + @Index({ unique: true }) + @Column({ comment: '键' }) + keyName: string; + + @Column({ comment: '名称' }) + name: string; + + @Column({ comment: '数据', type: 'text' }) + data: string; + + @Column({ + comment: '数据类型 0-字符串 1-富文本 2-文件 ', + default: 0, + }) + dataType: number; + + @Column({ comment: '备注', nullable: true }) + remark: string; +} diff --git a/src/modules/base/entity/sys/role.ts b/src/modules/base/entity/sys/role.ts new file mode 100644 index 0000000..c1498d1 --- /dev/null +++ b/src/modules/base/entity/sys/role.ts @@ -0,0 +1,31 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Index, Entity } from 'typeorm'; + +/** + * 角色 + */ +@Entity('base_sys_role') +export class BaseSysRoleEntity extends BaseEntity { + @Column({ comment: '用户ID' }) + userId: string; + + @Index({ unique: true }) + @Column({ comment: '名称' }) + name: string; + + @Index({ unique: true }) + @Column({ comment: '角色标签', nullable: true, length: 50 }) + label: string; + + @Column({ comment: '备注', nullable: true }) + remark: string; + + @Column({ comment: '数据权限是否关联上下级', default: false }) + relevance: boolean; + + @Column({ comment: '菜单权限', type: 'json' }) + menuIdList: number[]; + + @Column({ comment: '部门权限', type: 'json' }) + departmentIdList: number[]; +} diff --git a/src/modules/base/entity/sys/role_department.ts b/src/modules/base/entity/sys/role_department.ts new file mode 100644 index 0000000..e0a52a8 --- /dev/null +++ b/src/modules/base/entity/sys/role_department.ts @@ -0,0 +1,14 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity } from 'typeorm'; + +/** + * 角色部门 + */ +@Entity('base_sys_role_department') +export class BaseSysRoleDepartmentEntity extends BaseEntity { + @Column({ comment: '角色ID' }) + roleId: number; + + @Column({ comment: '部门ID' }) + departmentId: number; +} diff --git a/src/modules/base/entity/sys/role_menu.ts b/src/modules/base/entity/sys/role_menu.ts new file mode 100644 index 0000000..667e312 --- /dev/null +++ b/src/modules/base/entity/sys/role_menu.ts @@ -0,0 +1,14 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity } from 'typeorm'; + +/** + * 角色菜单 + */ +@Entity('base_sys_role_menu') +export class BaseSysRoleMenuEntity extends BaseEntity { + @Column({ comment: '角色ID' }) + roleId: number; + + @Column({ comment: '菜单ID' }) + menuId: number; +} diff --git a/src/modules/base/entity/sys/user.ts b/src/modules/base/entity/sys/user.ts new file mode 100644 index 0000000..a97e7c9 --- /dev/null +++ b/src/modules/base/entity/sys/user.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Index, Entity } from 'typeorm'; + +/** + * 系统用户 + */ +@Entity('base_sys_user') +export class BaseSysUserEntity extends BaseEntity { + @Index() + @Column({ comment: '部门ID', nullable: true }) + departmentId: number; + + @Column({ comment: '姓名', nullable: true }) + name: string; + + @Index({ unique: true }) + @Column({ comment: '用户名', length: 100 }) + username: string; + + @Column({ comment: '密码' }) + password: string; + + @Column({ + comment: '密码版本, 作用是改完密码,让原来的token失效', + default: 1, + }) + passwordV: number; + + @Column({ comment: '昵称', nullable: true }) + nickName: string; + + @Column({ comment: '头像', nullable: true }) + headImg: string; + + @Index() + @Column({ comment: '手机', nullable: true, length: 20 }) + phone: string; + + @Column({ comment: '邮箱', nullable: true }) + email: string; + + @Column({ comment: '备注', nullable: true }) + remark: string; + + @Column({ comment: '状态 0-禁用 1-启用', default: 1 }) + status: number; + // 部门名称 + departmentName: string; + // 角色ID列表 + roleIdList: number[]; + + @Column({ comment: 'socketId', nullable: true }) + socketId: string; +} diff --git a/src/modules/base/entity/sys/user_role.ts b/src/modules/base/entity/sys/user_role.ts new file mode 100644 index 0000000..c15f00c --- /dev/null +++ b/src/modules/base/entity/sys/user_role.ts @@ -0,0 +1,14 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity } from 'typeorm'; + +/** + * 用户角色 + */ +@Entity('base_sys_user_role') +export class BaseSysUserRoleEntity extends BaseEntity { + @Column({ comment: '用户ID' }) + userId: number; + + @Column({ comment: '角色ID' }) + roleId: number; +} diff --git a/src/modules/base/event/app.ts b/src/modules/base/event/app.ts new file mode 100644 index 0000000..9f75703 --- /dev/null +++ b/src/modules/base/event/app.ts @@ -0,0 +1,92 @@ +import { CoolEvent, Event } from '@cool-midway/core'; +import { App, Config, ILogger, Logger } from '@midwayjs/core'; +import { IMidwayKoaApplication } from '@midwayjs/koa'; +import * as fs from 'fs'; +import * as path from 'path'; +import { v1 as uuid } from 'uuid'; + +/** + * 修改jwt.secret + */ +@CoolEvent() +export class BaseAppEvent { + @Logger() + coreLogger: ILogger; + + @Config('module') + config; + + @Config('keys') + configKeys; + + @Config('koa.port') + port; + + @App() + app: IMidwayKoaApplication; + + @Event('onMenuInit') + async onMenuInit() { + this.checkConfig(); + this.checkKeys(); + } + + /** + * 检查配置 + */ + async checkConfig() { + if (this.config.base.jwt.secret == 'cool-admin-xxxxxx') { + this.coreLogger.warn( + '\x1B[36m 检测到模块[base] jwt.secret 配置是默认值,请不要关闭!即将自动修改... \x1B[0m' + ); + setTimeout(() => { + const filePath = path.join( + this.app.getBaseDir(), + 'modules', + 'base', + 'config.ts' + ); + // 替换文件内容 + let fileData = fs.readFileSync(filePath, 'utf8'); + const secret = uuid().replace(/-/g, ''); + this.config.base.jwt.secret = secret; + fs.writeFileSync( + filePath, + fileData.replace('cool-admin-xxxxxx', secret) + ); + this.coreLogger.info( + '\x1B[36m [cool:module:base] midwayjs cool module base auto modify jwt.secret\x1B[0m' + ); + }, 6000); + } + } + + /** + * 检查keys + */ + async checkKeys() { + if (this.configKeys == 'cool-admin-keys-xxxxxx') { + this.coreLogger.warn( + '\x1B[36m 检测到基础配置[Keys] 是默认值,请不要关闭!即将自动修改... \x1B[0m' + ); + setTimeout(() => { + const filePath = path.join( + this.app.getBaseDir(), + 'config', + 'config.default.ts' + ); + // 替换文件内容 + let fileData = fs.readFileSync(filePath, 'utf8'); + const secret = uuid().replace(/-/g, ''); + this.config.base.jwt.secret = secret; + fs.writeFileSync( + filePath, + fileData.replace('cool-admin-keys-xxxxxx', secret) + ); + this.coreLogger.info( + '\x1B[36m [cool:module:base] midwayjs cool keys auto modify \x1B[0m' + ); + }, 6000); + } + } +} diff --git a/src/modules/base/event/menu.ts b/src/modules/base/event/menu.ts new file mode 100644 index 0000000..edcbaf1 --- /dev/null +++ b/src/modules/base/event/menu.ts @@ -0,0 +1,36 @@ +import { CoolEvent, Event } from '@cool-midway/core'; +import { BaseSysMenuService } from '../service/sys/menu'; +import { + App, + ILogger, + IMidwayApplication, + Inject, + Logger, +} from '@midwayjs/core'; + +/** + * 导入菜单 + */ +@CoolEvent() +export class BaseMenuEvent { + @Logger() + coreLogger: ILogger; + + @Inject() + baseSysMenuService: BaseSysMenuService; + + @App() + app: IMidwayApplication; + + @Event('onMenuImport') + async onMenuImport(datas) { + for (const module in datas) { + await this.baseSysMenuService.import(datas[module]); + this.coreLogger.info( + '\x1B[36m [cool:module:base] midwayjs cool module base import [' + + module + + '] module menu success \x1B[0m' + ); + } + } +} diff --git a/src/modules/base/job/log.ts b/src/modules/base/job/log.ts new file mode 100644 index 0000000..0e35727 --- /dev/null +++ b/src/modules/base/job/log.ts @@ -0,0 +1,25 @@ +import { Job, IJob } from '@midwayjs/cron'; +import { FORMAT, ILogger, Inject } from '@midwayjs/core'; +import { BaseSysLogService } from '../service/sys/log'; + +/** + * 日志定时任务 + */ +@Job({ + cronTime: FORMAT.CRONTAB.EVERY_DAY, + start: true, +}) +export class BaseLogJob implements IJob { + @Inject() + baseSysLogService: BaseSysLogService; + + @Inject() + logger: ILogger; + + async onTick() { + this.logger.info('清除日志定时任务开始执行'); + const startTime = Date.now(); + await this.baseSysLogService.clear(); + this.logger.info(`清除日志定时任务结束,耗时:${Date.now() - startTime}ms`); + } +} diff --git a/src/modules/base/menu.json b/src/modules/base/menu.json new file mode 100644 index 0000000..3a5a498 --- /dev/null +++ b/src/modules/base/menu.json @@ -0,0 +1,1571 @@ +[ + { + "name": "系统管理", + "router": "/sys", + "perms": null, + "type": 0, + "icon": "icon-system", + "orderNum": 9, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "权限管理", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-auth", + "orderNum": 1, + "viewPath": null, + "keepAlive": false, + "isShow": true, + "childMenus": [ + { + "name": "菜单列表", + "router": "/sys/menu", + "perms": null, + "type": 1, + "icon": "icon-menu", + "orderNum": 2, + "viewPath": "modules/base/views/menu/index.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "新增", + "router": null, + "perms": "base:sys:menu:add", + "type": 2, + "icon": null, + "orderNum": 1, + "viewPath": null, + "keepAlive": false, + "isShow": true, + "childMenus": [] + }, + { + "name": "删除", + "router": null, + "perms": "base:sys:menu:delete", + "type": 2, + "icon": null, + "orderNum": 2, + "viewPath": null, + "keepAlive": false, + "isShow": true, + "childMenus": [] + }, + { + "name": "查询", + "router": null, + "perms": "base:sys:menu:page,base:sys:menu:list,base:sys:menu:info", + "type": 2, + "icon": null, + "orderNum": 4, + "viewPath": null, + "keepAlive": false, + "isShow": true, + "childMenus": [] + }, + { + "name": "参数", + "router": "/test/aa", + "perms": null, + "type": 1, + "icon": "icon-goods", + "orderNum": 0, + "viewPath": "modules/base/views/info.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "编辑", + "router": null, + "perms": "base:sys:menu:info,base:sys:menu:update", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "角色列表", + "router": "/sys/role", + "perms": null, + "type": 1, + "icon": "icon-dept", + "orderNum": 3, + "viewPath": "cool/modules/base/views/role.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "新增", + "router": null, + "perms": "base:sys:role:add", + "type": 2, + "icon": null, + "orderNum": 1, + "viewPath": null, + "keepAlive": false, + "isShow": true, + "childMenus": [] + }, + { + "name": "删除", + "router": null, + "perms": "base:sys:role:delete", + "type": 2, + "icon": null, + "orderNum": 2, + "viewPath": null, + "keepAlive": false, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "base:sys:role:update", + "type": 2, + "icon": null, + "orderNum": 3, + "viewPath": null, + "keepAlive": false, + "isShow": true, + "childMenus": [] + }, + { + "name": "查询", + "router": null, + "perms": "base:sys:role:page,base:sys:role:list,base:sys:role:info", + "type": 2, + "icon": null, + "orderNum": 4, + "viewPath": null, + "keepAlive": false, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "用户列表", + "router": "/sys/user", + "perms": null, + "type": 1, + "icon": "icon-user", + "orderNum": 0, + "viewPath": "modules/base/views/user/index.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "部门列表", + "router": null, + "perms": "base:sys:department:list", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "新增部门", + "router": null, + "perms": "base:sys:department:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "更新部门", + "router": null, + "perms": "base:sys:department:update", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "删除部门", + "router": null, + "perms": "base:sys:department:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "部门排序", + "router": null, + "perms": "base:sys:department:order", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "用户转移", + "router": null, + "perms": "base:sys:user:move", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "新增", + "router": null, + "perms": "base:sys:user:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "删除", + "router": null, + "perms": "base:sys:user:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "base:sys:user:delete,base:sys:user:update", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "查询", + "router": null, + "perms": "base:sys:user:page,base:sys:user:list,base:sys:user:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + } + ] + }, + { + "name": "参数配置", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-params", + "orderNum": 3, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "参数列表", + "router": "/sys/param", + "perms": null, + "type": 1, + "icon": "icon-menu", + "orderNum": 0, + "viewPath": "cool/modules/base/views/param.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "新增", + "router": null, + "perms": "base:sys:param:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "base:sys:param:info,base:sys:param:update", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "删除", + "router": null, + "perms": "base:sys:param:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "查看", + "router": null, + "perms": "base:sys:param:page,base:sys:param:list,base:sys:param:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + } + ] + }, + { + "name": "监控管理", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-monitor", + "orderNum": 9, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "请求日志", + "router": "/sys/log", + "perms": null, + "type": 1, + "icon": "icon-log", + "orderNum": 1, + "viewPath": "cool/modules/base/views/log.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "权限", + "router": null, + "perms": "base:sys:log:page,base:sys:log:clear,base:sys:log:getKeep,base:sys:log:setKeep", + "type": 2, + "icon": null, + "orderNum": 1, + "viewPath": null, + "keepAlive": false, + "isShow": true, + "childMenus": [] + } + ] + } + ] + }, + { + "name": "任务管理", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-activity", + "orderNum": 9, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "任务列表", + "router": "/task/list", + "perms": null, + "type": 1, + "icon": "icon-menu", + "orderNum": 0, + "viewPath": "modules/task/views/list.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "权限", + "router": null, + "perms": "task:info:page,task:info:list,task:info:info,task:info:add,task:info:delete,task:info:update,task:info:stop,task:info:start,task:info:once,task:info:log", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + } + ] + } + ] + }, + { + "name": "框架教程", + "router": "/tutorial", + "perms": null, + "type": 0, + "icon": "icon-task", + "orderNum": 98, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "文档官网", + "router": "/tutorial/doc", + "perms": null, + "type": 1, + "icon": "icon-log", + "orderNum": 0, + "viewPath": "https://admin.cool-js.com", + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "crud 示例", + "router": "/demo/crud", + "perms": null, + "type": 1, + "icon": "icon-favor", + "orderNum": 1, + "viewPath": "modules/demo/views/crud/index.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "通用", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-radioboxfill", + "orderNum": 99, + "viewPath": null, + "keepAlive": true, + "isShow": false, + "childMenus": [ + { + "name": "图片上传", + "router": null, + "perms": "space:info:page,space:info:list,space:info:info,space:info:add,space:info:delete,space:info:update,space:type:page,space:type:list,space:type:info,space:type:add,space:type:delete,space:type:update", + "type": 2, + "icon": null, + "orderNum": 1, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "首页", + "router": "/", + "perms": null, + "type": 1, + "icon": null, + "orderNum": 0, + "viewPath": "modules/demo/views/home/index.vue", + "keepAlive": true, + "isShow": false, + "childMenus": [] + }, + { + "name": "数据管理", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-data", + "orderNum": 7, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "字典管理", + "router": "/dict/list", + "perms": null, + "type": 1, + "icon": "icon-dict", + "orderNum": 3, + "viewPath": "modules/dict/views/list.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "删除", + "router": null, + "perms": "dict:info:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "dict:info:update,dict:info:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "获得字典数据", + "router": null, + "perms": "dict:info:data", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "单个信息", + "router": null, + "perms": "dict:info:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "列表查询", + "router": null, + "perms": "dict:info:list", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "分页查询", + "router": null, + "perms": "dict:info:page", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "新增", + "router": null, + "perms": "dict:info:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "组权限", + "router": null, + "perms": "dict:type:list,dict:type:update,dict:type:delete,dict:type:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "数据回收站", + "router": "/recycle/data", + "perms": null, + "type": 1, + "icon": "icon-delete", + "orderNum": 6, + "viewPath": "modules/recycle/views/data.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "恢复数据", + "router": null, + "perms": "recycle:data:restore", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "单个信息", + "router": null, + "perms": "recycle:data:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "分页查询", + "router": null, + "perms": "recycle:data:page", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "文件管理", + "router": "/upload/list", + "perms": null, + "type": 1, + "icon": "icon-log", + "orderNum": 5, + "viewPath": "modules/space/views/list.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "权限", + "router": null, + "perms": "space:type:delete,space:type:update,space:type:info,space:type:list,space:type:page,space:type:add,space:info:getConfig,space:info:delete,space:info:update,space:info:info,space:info:list,space:info:page,space:info:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + } + ] + }, + { + "name": "用户管理", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-user", + "orderNum": 11, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "用户列表", + "router": "/user/list", + "perms": null, + "type": 1, + "icon": "icon-menu", + "orderNum": 1, + "viewPath": "modules/user/views/list.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "删除", + "router": null, + "perms": "user:info:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "user:info:update,user:info:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "单个信息", + "router": null, + "perms": "user:info:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "列表查询", + "router": null, + "perms": "user:info:list", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "分页查询", + "router": null, + "perms": "user:info:page", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "新增", + "router": null, + "perms": "user:info:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + } + ] + }, + { + "name": "扩展管理", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-favor", + "orderNum": 8, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "后端插件", + "router": "/helper/plugins/serve", + "perms": null, + "type": 1, + "icon": "icon-component", + "orderNum": 2, + "viewPath": "modules/helper/views/plugins/serve.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "权限", + "router": null, + "perms": "plugin:info:install,plugin:info:delete,plugin:info:update,plugin:info:page,plugin:info:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "前端插件", + "router": "/helper/plugins/vue", + "perms": null, + "type": 1, + "icon": "icon-vue", + "orderNum": 1, + "viewPath": "modules/helper/views/plugins/vue.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "商品管理", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-goods", + "orderNum": 1, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "商品列表", + "router": "/goods/info", + "perms": null, + "type": 1, + "icon": "icon-menu", + "orderNum": 1, + "viewPath": "modules/goods/views/info.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "删除", + "router": null, + "perms": "goods:info:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "goods:info:update,goods:info:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "单个信息", + "router": null, + "perms": "goods:info:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "列表查询", + "router": null, + "perms": "goods:info:list", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "分页查询", + "router": null, + "perms": "goods:info:page", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "新增", + "router": null, + "perms": "goods:info:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "分类列表", + "router": "/goods/type", + "perms": null, + "type": 1, + "icon": "icon-component", + "orderNum": 2, + "viewPath": "modules/goods/views/type.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "删除", + "router": null, + "perms": "goods:type:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "goods:type:update,goods:type:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "单个信息", + "router": null, + "perms": "goods:type:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "列表查询", + "router": null, + "perms": "goods:type:list", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "分页查询", + "router": null, + "perms": "goods:type:page", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "新增", + "router": null, + "perms": "goods:type:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "评论列表", + "router": "/goods/comment", + "perms": null, + "type": 1, + "icon": "icon-message", + "orderNum": 3, + "viewPath": "modules/goods/views/comment.vue", + "keepAlive": false, + "isShow": false, + "childMenus": [ + { + "name": "分页查询", + "router": null, + "perms": "goods:comment:page", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "搜索关键词", + "router": "/goods/search-keyword", + "perms": null, + "type": 1, + "icon": "icon-hot", + "orderNum": 4, + "viewPath": "modules/goods/views/search-keyword.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "删除", + "router": null, + "perms": "goods:searchKeyword:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "goods:searchKeyword:update,goods:searchKeyword:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "单个信息", + "router": null, + "perms": "goods:searchKeyword:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "列表查询", + "router": null, + "perms": "goods:searchKeyword:list", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "分页查询", + "router": null, + "perms": "goods:searchKeyword:page", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "新增", + "router": null, + "perms": "goods:searchKeyword:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + } + ] + }, + { + "name": "订单管理", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-log", + "orderNum": 2, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "订单列表", + "router": "/order/info", + "perms": null, + "type": 1, + "icon": "icon-menu", + "orderNum": 1, + "viewPath": "modules/order/views/info.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "退款处理", + "router": null, + "perms": "order:info:refundHandle", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "物流信息", + "router": null, + "perms": "order:info:logistics", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "发货", + "router": null, + "perms": "order:info:deliver", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "删除", + "router": null, + "perms": "order:info:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "order:info:update,order:info:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "单个信息", + "router": null, + "perms": "order:info:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "列表查询", + "router": null, + "perms": "order:info:list", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "分页查询", + "router": null, + "perms": "order:info:page", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "退款列表", + "router": "/order/refund", + "perms": null, + "type": 1, + "icon": "icon-amount", + "orderNum": 2, + "viewPath": "modules/order/views/refund.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "活动管理", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-activity", + "orderNum": 3, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "优惠券列表", + "router": "/market/coupon/info", + "perms": null, + "type": 1, + "icon": "icon-activity", + "orderNum": 1, + "viewPath": "modules/market/views/coupon/info.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "删除", + "router": null, + "perms": "market:coupon:info:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "market:coupon:info:update,market:coupon:info:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "单个信息", + "router": null, + "perms": "market:coupon:info:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "列表查询", + "router": null, + "perms": "market:coupon:info:list", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "分页查询", + "router": null, + "perms": "market:coupon:info:page", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "新增", + "router": null, + "perms": "market:coupon:info:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + }, + { + "name": "领取列表", + "router": "/market/coupon/user", + "perms": null, + "type": 1, + "icon": "icon-menu", + "orderNum": 2, + "viewPath": "modules/market/views/coupon/user.vue", + "keepAlive": false, + "isShow": false, + "childMenus": [ + { + "name": "删除", + "router": null, + "perms": "market:coupon:user:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "market:coupon:user:update,market:coupon:user:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "单个信息", + "router": null, + "perms": "market:coupon:user:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "列表查询", + "router": null, + "perms": "market:coupon:user:list", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "分页查询", + "router": null, + "perms": "market:coupon:user:page", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "新增", + "router": null, + "perms": "market:coupon:user:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + } + ] + }, + { + "name": "信息管理", + "router": null, + "perms": null, + "type": 0, + "icon": "icon-dict", + "orderNum": 5, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "轮播图列表", + "router": "/info/banner", + "perms": null, + "type": 1, + "icon": "icon-pic", + "orderNum": 1, + "viewPath": "modules/info/views/banner.vue", + "keepAlive": true, + "isShow": true, + "childMenus": [ + { + "name": "删除", + "router": null, + "perms": "info:banner:delete", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "修改", + "router": null, + "perms": "info:banner:update,info:banner:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "单个信息", + "router": null, + "perms": "info:banner:info", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "列表查询", + "router": null, + "perms": "info:banner:list", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "分页查询", + "router": null, + "perms": "info:banner:page", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + }, + { + "name": "新增", + "router": null, + "perms": "info:banner:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + } + ] + } +] \ No newline at end of file diff --git a/src/modules/base/middleware/authority.ts b/src/modules/base/middleware/authority.ts new file mode 100644 index 0000000..47e4381 --- /dev/null +++ b/src/modules/base/middleware/authority.ts @@ -0,0 +1,185 @@ +import { App, Config, Inject, Middleware } from '@midwayjs/decorator'; +import * as _ from 'lodash'; +import { CoolUrlTagData, RESCODE, TagTypes } from '@cool-midway/core'; +import * as jwt from 'jsonwebtoken'; +import { NextFunction, Context } from '@midwayjs/koa'; +import { + IMiddleware, + IMidwayApplication, + Init, + InjectClient, +} from '@midwayjs/core'; +import { CachingFactory, MidwayCache } from '@midwayjs/cache-manager'; + +/** + * 权限校验 + */ +@Middleware() +export class BaseAuthorityMiddleware + implements IMiddleware +{ + @Config('koa.globalPrefix') + prefix; + + @Config('module.base') + jwtConfig; + + @InjectClient(CachingFactory, 'default') + midwayCache: MidwayCache; + + @Inject() + coolUrlTagData: CoolUrlTagData; + + @App() + app: IMidwayApplication; + + ignoreUrls: string[] = []; + + @Init() + async init() { + this.ignoreUrls = this.coolUrlTagData.byKey(TagTypes.IGNORE_TOKEN, 'admin'); + } + + resolve() { + return async (ctx: Context, next: NextFunction) => { + let statusCode = 200; + let { url } = ctx; + url = url.replace(this.prefix, '').split('?')[0]; + const token = ctx.get('Authorization'); + const adminUrl = '/admin/'; + // 路由地址为 admin前缀的 需要权限校验 + if (_.startsWith(url, adminUrl)) { + try { + ctx.admin = jwt.verify(token, this.jwtConfig.jwt.secret); + if (ctx.admin.isRefresh) { + ctx.status = 401; + ctx.body = { + code: RESCODE.COMMFAIL, + message: '登录失效~', + }; + return; + } + } catch (error) {} + // 使用matchUrl方法来检查URL是否应该被忽略 + const isIgnored = this.ignoreUrls.some(pattern => + this.matchUrl(pattern, url) + ); + if (isIgnored) { + await next(); + return; + } + if (ctx.admin) { + const rToken = await this.midwayCache.get( + `admin:token:${ctx.admin.userId}` + ); + // 判断密码版本是否正确 + const passwordV = await this.midwayCache.get( + `admin:passwordVersion:${ctx.admin.userId}` + ); + if (passwordV != ctx.admin.passwordVersion) { + ctx.status = 401; + ctx.body = { + code: RESCODE.COMMFAIL, + message: '登录失效~', + }; + return; + } + // 超管拥有所有权限 + if (ctx.admin.username == 'admin' && !ctx.admin.isRefresh) { + if (rToken !== token && this.jwtConfig.jwt.sso) { + ctx.status = 401; + ctx.body = { + code: RESCODE.COMMFAIL, + message: '登录失效~', + }; + return; + } else { + await next(); + return; + } + } + // 要登录每个人都有权限的接口 + if ( + new RegExp(`^${adminUrl}?.*/comm/`).test(url) || + // 字典接口 + url == '/admin/dict/info/data' + ) { + await next(); + return; + } + // 如果传的token是refreshToken则校验失败 + if (ctx.admin.isRefresh) { + ctx.status = 401; + ctx.body = { + code: RESCODE.COMMFAIL, + message: '登录失效~', + }; + return; + } + if (!rToken) { + ctx.status = 401; + ctx.body = { + code: RESCODE.COMMFAIL, + message: '登录失效或无权限访问~', + }; + return; + } + if (rToken !== token && this.jwtConfig.jwt.sso) { + statusCode = 401; + } else { + let perms: string[] = await this.midwayCache.get( + `admin:perms:${ctx.admin.userId}` + ); + if (!_.isEmpty(perms)) { + perms = perms.map(e => { + return e.replace(/:/g, '/'); + }); + if (!perms.includes(url.split('?')[0].replace('/admin/', ''))) { + statusCode = 403; + } + } else { + statusCode = 403; + } + } + } else { + statusCode = 401; + } + if (statusCode > 200) { + ctx.status = statusCode; + ctx.body = { + code: RESCODE.COMMFAIL, + message: '登录失效或无权限访问~', + }; + return; + } + } + await next(); + }; + } + + // 匹配URL的方法 + matchUrl(pattern, url) { + const patternSegments = pattern.split('/').filter(Boolean); + const urlSegments = url.split('/').filter(Boolean); + + // 如果段的数量不同,则无法匹配 + if (patternSegments.length !== urlSegments.length) { + return false; + } + + // 逐段进行匹配 + for (let i = 0; i < patternSegments.length; i++) { + if (patternSegments[i].startsWith(':')) { + // 如果模式段以':'开始,我们认为它是一个参数,可以匹配任何内容 + continue; + } + // 如果两个段不相同,则不匹配 + if (patternSegments[i] !== urlSegments[i]) { + return false; + } + } + + // 所有段都匹配 + return true; + } +} diff --git a/src/modules/base/middleware/log.ts b/src/modules/base/middleware/log.ts new file mode 100644 index 0000000..2b3486e --- /dev/null +++ b/src/modules/base/middleware/log.ts @@ -0,0 +1,26 @@ +import { Middleware } from '@midwayjs/decorator'; +import * as _ from 'lodash'; +import { NextFunction, Context } from '@midwayjs/koa'; +import { IMiddleware } from '@midwayjs/core'; +import { BaseSysLogService } from '../service/sys/log'; + +/** + * 日志中间件 + */ +@Middleware() +export class BaseLogMiddleware implements IMiddleware { + resolve() { + return async (ctx: Context, next: NextFunction) => { + const baseSysLogService = await ctx.requestContext.getAsync( + BaseSysLogService + ); + baseSysLogService.record( + ctx, + ctx.url, + ctx.req.method === 'GET' ? ctx.request.query : ctx.request.body, + ctx.admin ? ctx.admin.userId : null + ); + await next(); + }; + } +} diff --git a/src/modules/base/service/sys/conf.ts b/src/modules/base/service/sys/conf.ts new file mode 100644 index 0000000..dbff81d --- /dev/null +++ b/src/modules/base/service/sys/conf.ts @@ -0,0 +1,39 @@ +import { Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseSysConfEntity } from '../../entity/sys/conf'; + +/** + * 系统配置 + */ +@Provide() +export class BaseSysConfService extends BaseService { + @InjectEntityModel(BaseSysConfEntity) + baseSysConfEntity: Repository; + + /** + * 获得配置参数值 + * @param key + */ + async getValue(key) { + const conf = await this.baseSysConfEntity.findOneBy({ cKey: key }); + if (conf) { + return conf.cValue; + } + } + + /** + * 更新配置参数 + * @param cKey + * @param cValue + */ + async updateVaule(cKey, cValue) { + await this.baseSysConfEntity + .createQueryBuilder() + .update() + .where({ cKey }) + .set({ cKey, cValue }) + .execute(); + } +} diff --git a/src/modules/base/service/sys/data.ts b/src/modules/base/service/sys/data.ts new file mode 100644 index 0000000..42b03c9 --- /dev/null +++ b/src/modules/base/service/sys/data.ts @@ -0,0 +1,10 @@ +import { DataSource } from 'typeorm'; + +export class TempDataSource extends DataSource { + /** + * 重新构造元数据 + */ + async buildMetadatas() { + await super.buildMetadatas(); + } +} diff --git a/src/modules/base/service/sys/department.ts b/src/modules/base/service/sys/department.ts new file mode 100644 index 0000000..714e882 --- /dev/null +++ b/src/modules/base/service/sys/department.ts @@ -0,0 +1,124 @@ +import { Inject, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { BaseSysDepartmentEntity } from '../../entity/sys/department'; +import * as _ from 'lodash'; +import { BaseSysRoleDepartmentEntity } from '../../entity/sys/role_department'; +import { BaseSysPermsService } from './perms'; +import { BaseSysUserEntity } from '../../entity/sys/user'; + +/** + * 描述 + */ +@Provide() +export class BaseSysDepartmentService extends BaseService { + @InjectEntityModel(BaseSysDepartmentEntity) + baseSysDepartmentEntity: Repository; + + @InjectEntityModel(BaseSysUserEntity) + baseSysUserEntity: Repository; + + @InjectEntityModel(BaseSysRoleDepartmentEntity) + baseSysRoleDepartmentEntity: Repository; + + @Inject() + baseSysPermsService: BaseSysPermsService; + + @Inject() + ctx; + + /** + * 获得部门菜单 + */ + async list() { + // 部门权限 + const permsDepartmentArr = await this.baseSysPermsService.departmentIds( + this.ctx.admin.userId + ); + + // 过滤部门权限 + const find = this.baseSysDepartmentEntity.createQueryBuilder('a'); + if (this.ctx.admin.username !== 'admin') + find.andWhere('a.id in (:...ids)', { + ids: !_.isEmpty(permsDepartmentArr) ? permsDepartmentArr : [null], + }); + find.addOrderBy('a.orderNum', 'ASC'); + const departments: BaseSysDepartmentEntity[] = await find.getMany(); + + if (!_.isEmpty(departments)) { + departments.forEach(e => { + const parentMenu = departments.filter(m => { + e.parentId = parseInt(e.parentId + ''); + if (e.parentId == m.id) { + return m.name; + } + }); + if (!_.isEmpty(parentMenu)) { + e.parentName = parentMenu[0].name; + } + }); + } + return departments; + } + + /** + * 根据多个ID获得部门权限信息 + * @param {[]} roleIds 数组 + * @param isAdmin 是否超管 + */ + async getByRoleIds(roleIds: number[], isAdmin) { + if (!_.isEmpty(roleIds)) { + if (isAdmin) { + const result = await this.baseSysDepartmentEntity.find(); + return result.map(e => { + return e.id; + }); + } + const result = await this.baseSysRoleDepartmentEntity + .createQueryBuilder('a') + .where('a.roleId in (:...roleIds)', { roleIds }) + .getMany(); + if (!_.isEmpty(result)) { + return _.uniq( + result.map(e => { + return e.departmentId; + }) + ); + } + } + return []; + } + + /** + * 部门排序 + * @param params + */ + async order(params) { + for (const e of params) { + await this.baseSysDepartmentEntity.update(e.id, e); + } + } + + /** + * 删除 + */ + async delete(ids: number[]) { + const { deleteUser } = this.ctx.request.body; + await super.delete(ids); + if (deleteUser) { + await this.baseSysUserEntity.delete({ departmentId: In(ids) }); + } else { + const topDepartment = await this.baseSysDepartmentEntity + .createQueryBuilder('a') + .where('a.parentId is null') + .getOne(); + if (topDepartment) { + await this.baseSysUserEntity.update( + { departmentId: In(ids) }, + { departmentId: topDepartment.id } + ); + } + } + } +} diff --git a/src/modules/base/service/sys/log.ts b/src/modules/base/service/sys/log.ts new file mode 100644 index 0000000..e33de9d --- /dev/null +++ b/src/modules/base/service/sys/log.ts @@ -0,0 +1,66 @@ +import { Inject, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { LessThan, Repository } from 'typeorm'; +import * as _ from 'lodash'; +import { BaseSysLogEntity } from '../../entity/sys/log'; +import * as moment from 'moment'; +import { Utils } from '../../../../comm/utils'; +import { BaseSysConfService } from './conf'; +import { Context } from '@midwayjs/koa'; + +/** + * 描述 + */ +@Provide() +export class BaseSysLogService extends BaseService { + @Inject() + ctx; + + @Inject() + utils: Utils; + + @InjectEntityModel(BaseSysLogEntity) + baseSysLogEntity: Repository; + + @Inject() + baseSysConfService: BaseSysConfService; + + /** + * 记录 + * @param url URL地址 + * @param params 参数 + * @param userId 用户ID + */ + async record(context: Context, url, params, userId) { + const ip = await this.utils.getReqIP(context); + const sysLog = new BaseSysLogEntity(); + sysLog.userId = userId; + sysLog.ip = typeof ip === 'string' ? ip : ip.join(','); + const ipAddrArr = []; + for (const e of sysLog.ip.split(',')) + ipAddrArr.push(await this.utils.getIpAddr(context, e)); + sysLog.ipAddr = ipAddrArr.join(','); + sysLog.action = url.split('?')[0]; + sysLog.params = params; + await this.baseSysLogEntity.insert(sysLog); + } + + /** + * 日志 + * @param isAll 是否清除全部 + */ + async clear(isAll?) { + if (isAll) { + await this.baseSysLogEntity.clear(); + return; + } + const keepDay = await this.baseSysConfService.getValue('logKeep'); + if (keepDay) { + const beforeDate = moment().add(-keepDay, 'days').startOf('day').toDate(); + await this.baseSysLogEntity.delete({ createTime: LessThan(beforeDate) }); + } else { + await this.baseSysLogEntity.clear(); + } + } +} diff --git a/src/modules/base/service/sys/login.ts b/src/modules/base/service/sys/login.ts new file mode 100644 index 0000000..f4faf87 --- /dev/null +++ b/src/modules/base/service/sys/login.ts @@ -0,0 +1,251 @@ +import { Inject, Provide, Config, InjectClient } from '@midwayjs/decorator'; +import { BaseService, CoolCommException } from '@cool-midway/core'; +import { LoginDTO } from '../../dto/login'; +import * as svgCaptcha from 'svg-captcha'; +import { v1 as uuid } from 'uuid'; +import { BaseSysUserEntity } from '../../entity/sys/user'; +import { Repository } from 'typeorm'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import * as md5 from 'md5'; +import { BaseSysRoleService } from './role'; +import * as _ from 'lodash'; +import { BaseSysMenuService } from './menu'; +import { BaseSysDepartmentService } from './department'; +import * as jwt from 'jsonwebtoken'; +import * as svgToDataURL from 'mini-svg-data-uri'; +import { Context } from '@midwayjs/koa'; +import { CachingFactory, MidwayCache } from '@midwayjs/cache-manager'; +import { readFileSync } from 'fs'; +const { svg2png, initialize } = require('svg2png-wasm'); +initialize(readFileSync('./node_modules/svg2png-wasm/svg2png_wasm_bg.wasm')); + +/** + * 登录 + */ +@Provide() +export class BaseSysLoginService extends BaseService { + @InjectClient(CachingFactory, 'default') + midwayCache: MidwayCache; + + @InjectEntityModel(BaseSysUserEntity) + baseSysUserEntity: Repository; + + @Inject() + baseSysRoleService: BaseSysRoleService; + + @Inject() + baseSysMenuService: BaseSysMenuService; + + @Inject() + baseSysDepartmentService: BaseSysDepartmentService; + + @Inject() + ctx: Context; + + @Config('module.base') + coolConfig; + + /** + * 登录 + * @param login + */ + async login(login: LoginDTO) { + const { username, captchaId, verifyCode, password } = login; + // 校验验证码 + const checkV = await this.captchaCheck(captchaId, verifyCode); + if (checkV) { + const user = await this.baseSysUserEntity.findOneBy({ username }); + // 校验用户 + if (user) { + // 校验用户状态及密码 + if (user.status === 0 || user.password !== md5(password)) { + throw new CoolCommException('账户或密码不正确~'); + } + } else { + throw new CoolCommException('账户或密码不正确~'); + } + // 校验角色 + const roleIds = await this.baseSysRoleService.getByUser(user.id); + if (_.isEmpty(roleIds)) { + throw new CoolCommException('该用户未设置任何角色,无法登录~'); + } + + // 生成token + const { expire, refreshExpire } = this.coolConfig.jwt.token; + const result = { + expire, + token: await this.generateToken(user, roleIds, expire), + refreshExpire, + refreshToken: await this.generateToken( + user, + roleIds, + refreshExpire, + true + ), + }; + + // 将用户相关信息保存到缓存 + const perms = await this.baseSysMenuService.getPerms(roleIds); + const departments = await this.baseSysDepartmentService.getByRoleIds( + roleIds, + user.username === 'admin' + ); + await this.midwayCache.set(`admin:department:${user.id}`, departments); + await this.midwayCache.set(`admin:perms:${user.id}`, perms); + await this.midwayCache.set(`admin:token:${user.id}`, result.token); + await this.midwayCache.set( + `admin:token:refresh:${user.id}`, + result.token + ); + + return result; + } else { + throw new CoolCommException('验证码不正确'); + } + } + + /** + * 验证码 + * @param type 图片验证码类型 svg + * @param width 宽 + * @param height 高 + */ + async captcha(type: string, width = 150, height = 50, color = '#fff') { + const svg = svgCaptcha.create({ + ignoreChars: 'qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM', + width, + height, + }); + const result = { + captchaId: uuid(), + data: svg.data.replace(/"/g, "'"), + }; + // 文字变白 + const rpList = [ + '#111', + '#222', + '#333', + '#444', + '#555', + '#666', + '#777', + '#888', + '#999', + ]; + rpList.forEach(rp => { + result.data = result.data['replaceAll'](rp, color); + }); + if (type === 'base64') { + result.data = svgToDataURL(result.data); + } + if (type === 'png') { + result.data = await svg2png(result.data, { + scale: 2, // optional + width, // optional + height, // optional + backgroundColor: 'white', // optional + }); + result.data = + 'data:image/png;base64,' + + Buffer.from(result.data, 'binary').toString('base64'); + } + // 半小时过期 + await this.midwayCache.set( + `verify:img:${result.captchaId}`, + svg.text.toLowerCase(), + 1800 * 1000 + ); + return result; + } + + /** + * 退出登录 + */ + async logout() { + if (!this.coolConfig.jwt.sso) return; + const { userId } = this.ctx.admin; + await this.midwayCache.del(`admin:department:${userId}`); + await this.midwayCache.del(`admin:perms:${userId}`); + await this.midwayCache.del(`admin:token:${userId}`); + await this.midwayCache.del(`admin:token:refresh:${userId}`); + await this.midwayCache.del(`admin:passwordVersion:${userId}`); + } + + /** + * 检验图片验证码 + * @param captchaId 验证码ID + * @param value 验证码 + */ + async captchaCheck(captchaId, value) { + const rv = await this.midwayCache.get(`verify:img:${captchaId}`); + if (!rv || !value || value.toLowerCase() !== rv) { + return false; + } else { + this.midwayCache.del(`verify:img:${captchaId}`); + return true; + } + } + + /** + * 生成token + * @param user 用户对象 + * @param roleIds 角色集合 + * @param expire 过期 + * @param isRefresh 是否是刷新 + */ + async generateToken(user, roleIds, expire, isRefresh?) { + await this.midwayCache.set( + `admin:passwordVersion:${user.id}`, + user.passwordV + ); + const tokenInfo = { + isRefresh: false, + roleIds, + username: user.username, + userId: user.id, + passwordVersion: user.passwordV, + }; + if (isRefresh) { + tokenInfo.isRefresh = true; + } + return jwt.sign(tokenInfo, this.coolConfig.jwt.secret, { + expiresIn: expire, + }); + } + + /** + * 刷新token + * @param token + */ + async refreshToken(token: string) { + const decoded = jwt.verify(token, this.coolConfig.jwt.secret); + if (decoded && decoded['isRefresh']) { + delete decoded['exp']; + delete decoded['iat']; + + const { expire, refreshExpire } = this.coolConfig.jwt.token; + decoded['isRefresh'] = false; + const result = { + expire, + token: jwt.sign(decoded, this.coolConfig.jwt.secret, { + expiresIn: expire, + }), + refreshExpire, + refreshToken: '', + }; + decoded['isRefresh'] = true; + result.refreshToken = jwt.sign(decoded, this.coolConfig.jwt.secret, { + expiresIn: refreshExpire, + }); + await this.midwayCache.set( + `admin:passwordVersion:${decoded['userId']}`, + decoded['passwordVersion'] + ); + await this.midwayCache.set( + `admin:token:${decoded['userId']}`, + result.token + ); + return result; + } + } +} diff --git a/src/modules/base/service/sys/menu.ts b/src/modules/base/service/sys/menu.ts new file mode 100644 index 0000000..3d60d4a --- /dev/null +++ b/src/modules/base/service/sys/menu.ts @@ -0,0 +1,466 @@ +import { App, IMidwayApplication, Scope, ScopeEnum } from '@midwayjs/core'; +import { ALL, Config, Inject, Provide } from '@midwayjs/decorator'; +import { BaseService, CoolCommException } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { BaseSysMenuEntity } from '../../entity/sys/menu'; +import * as _ from 'lodash'; +import { BaseSysPermsService } from './perms'; +import { Context } from '@midwayjs/koa'; +import { TempDataSource } from './data'; +// eslint-disable-next-line node/no-unpublished-import +import * as ts from 'typescript'; +import * as fs from 'fs'; +import * as pathUtil from 'path'; +import { BaseSysRoleMenuEntity } from '../../entity/sys/role_menu'; +import { BaseSysUserRoleEntity } from '../../entity/sys/user_role'; + +/** + * 菜单 + */ +@Scope(ScopeEnum.Request, { allowDowngrade: true }) +@Provide() +export class BaseSysMenuService extends BaseService { + @Inject() + ctx: Context; + + @InjectEntityModel(BaseSysMenuEntity) + baseSysMenuEntity: Repository; + + @InjectEntityModel(BaseSysRoleMenuEntity) + baseSysRoleMenuEntity: Repository; + + @Inject() + baseSysPermsService: BaseSysPermsService; + + @Config(ALL) + config; + + @App() + app: IMidwayApplication; + + /** + * 获得所有菜单 + */ + async list() { + const menus = await this.getMenus( + this.ctx.admin.roleIds, + this.ctx.admin.username === 'admin' + ); + if (!_.isEmpty(menus)) { + menus.forEach((e: any) => { + const parentMenu = menus.filter(m => { + e.parentId = parseInt(e.parentId); + if (e.parentId == m.id) { + return m.name; + } + }); + if (!_.isEmpty(parentMenu)) { + e.parentName = parentMenu[0].name; + } + }); + } + return menus; + } + + /** + * 修改之后 + * @param param + */ + async modifyAfter(param) { + if (param.id) { + await this.refreshPerms(param.id); + } + } + + /** + * 根据角色获得权限信息 + * @param {[]} roleIds 数组 + */ + async getPerms(roleIds) { + let perms = []; + if (!_.isEmpty(roleIds)) { + const find = await this.baseSysMenuEntity.createQueryBuilder('a'); + if (!roleIds.includes(1)) { + find.innerJoinAndSelect( + BaseSysRoleMenuEntity, + 'b', + 'a.id = b.menuId AND b.roleId in (:...roleIds)', + { roleIds } + ); + } + find.where('a.perms is not NULL'); + const result = await find.getMany(); + if (result) { + result.forEach(d => { + if (d.perms) { + perms = perms.concat(d.perms.split(',')); + } + }); + } + perms = _.uniq(perms); + perms = _.remove(perms, n => { + return !_.isEmpty(n); + }); + } + return _.uniq(perms); + } + + /** + * 获得用户菜单信息 + * @param roleIds + * @param isAdmin 是否是超管 + */ + async getMenus(roleIds, isAdmin) { + const find = this.baseSysMenuEntity.createQueryBuilder('a'); + if (!isAdmin) { + find.innerJoinAndSelect( + BaseSysRoleMenuEntity, + 'b', + 'a.id = b.menuId AND b.roleId in (:...roleIds)', + { roleIds } + ); + } + find.orderBy('a.orderNum', 'ASC'); + const list = await find.getMany(); + return _.uniqBy(list, 'id'); + } + + /** + * 删除 + * @param ids + */ + async delete(ids) { + let idArr; + if (ids instanceof Array) { + idArr = ids; + } else { + idArr = ids.split(','); + } + for (const id of idArr) { + await this.baseSysMenuEntity.delete({ id }); + await this.delChildMenu(id); + } + } + + /** + * 删除子菜单 + * @param id + */ + private async delChildMenu(id) { + await this.refreshPerms(id); + const delMenu = await this.baseSysMenuEntity.findBy({ parentId: id }); + if (_.isEmpty(delMenu)) { + return; + } + const delMenuIds = delMenu.map(e => { + return e.id; + }); + await this.baseSysMenuEntity.delete(delMenuIds); + for (const menuId of delMenuIds) { + await this.delChildMenu(menuId); + } + } + + /** + * 更新权限 + * @param menuId + */ + async refreshPerms(menuId) { + const find = this.baseSysRoleMenuEntity.createQueryBuilder('a'); + find.leftJoinAndSelect(BaseSysUserRoleEntity, 'b', 'a.roleId = b.roleId'); + find.where('a.menuId = :menuId', { menuId: menuId }); + find.select('b.userId', 'userId'); + const users = await find.getRawMany(); + // 刷新admin权限 + await this.baseSysPermsService.refreshPerms(1); + if (!_.isEmpty(users)) { + // 刷新其他权限 + for (const user of _.uniqBy(users, 'userId')) { + await this.baseSysPermsService.refreshPerms(user.userId); + } + } + } + + /** + * 解析实体和Controller + * @param entityString + * @param controller + * @param module + */ + async parse(entityString: string, controller: string, module: string) { + const tempDataSource = new TempDataSource({ + ...this.config.typeorm.dataSource.default, + entities: [], + }); + // 连接数据库 + await tempDataSource.initialize(); + const { newCode, className, oldTableName } = this.parseCode(entityString); + const code = ts.transpile( + `${newCode} + tempDataSource.options.entities.push(${className}) + `, + { + emitDecoratorMetadata: true, + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2018, + removeComments: true, + experimentalDecorators: true, + noImplicitThis: true, + noUnusedLocals: true, + stripInternal: true, + skipLibCheck: true, + pretty: true, + declaration: true, + noImplicitAny: false, + } + ); + eval(code); + await tempDataSource.buildMetadatas(); + const meta = tempDataSource.getMetadata(className); + const columnArr = meta.columns; + await tempDataSource.destroy(); + + const commColums = []; + const columns = _.filter( + columnArr.map(e => { + return { + propertyName: e.propertyName, + type: typeof e.type == 'string' ? e.type : e.type.name.toLowerCase(), + length: e.length, + comment: e.comment, + nullable: e.isNullable, + }; + }), + o => { + if (['createTime', 'updateTime'].includes(o.propertyName)) { + commColums.push(o); + } + return o && !['createTime', 'updateTime'].includes(o.propertyName); + } + ).concat(commColums); + if (!controller) { + const tableNames = oldTableName.split('_'); + const fileName = tableNames[tableNames.length - 1]; + return { + columns, + className: className.replace('TEMP', ''), + tableName: oldTableName, + fileName, + path: `/admin/${module}/${fileName}`, + }; + } + const fileName = await this.fileName(controller); + return { + columns, + path: `/admin/${module}/${fileName}`, + }; + } + + /** + * 解析Entity类名 + * @param code + * @returns + */ + parseCode(code: string) { + try { + const oldClassName = code + .match('class(.*)extends')[1] + .replace(/\s*/g, ''); + const oldTableStart = code.indexOf('@Entity('); + const oldTableEnd = code.indexOf(')'); + + const oldTableName = code + .substring(oldTableStart + 9, oldTableEnd - 1) + .replace(/\s*/g, '') + // eslint-disable-next-line no-useless-escape + .replace(/\"/g, '') + // eslint-disable-next-line no-useless-escape + .replace(/\'/g, ''); + const className = `${oldClassName}TEMP`; + return { + newCode: code + .replace(oldClassName, className) + .replace(oldTableName, `func_${oldTableName}`), + className, + tableName: `func_${oldTableName}`, + oldTableName, + }; + } catch (err) { + throw new CoolCommException('代码结构不正确,请检查'); + } + } + + /** + * 创建代码 + * @param body body + */ + async create(body) { + const { module, entity, controller, fileName } = body; + const basePath = this.app.getBaseDir(); + // const fileName = await this.fileName(controller); + // 生成Entity + const entityPath = pathUtil.join( + basePath, + 'modules', + module, + 'entity', + `${fileName}.ts` + ); + // 生成Controller + const controllerPath = pathUtil.join( + basePath, + 'modules', + module, + 'controller', + 'admin', + `${fileName}.ts` + ); + this.createConfigFile(module); + this.createFile(entityPath, entity); + this.createFile(controllerPath, controller); + } + + /** + * 创建配置文件 + * @param module + */ + async createConfigFile(module: string) { + const basePath = this.app.getBaseDir(); + const configFilePath = pathUtil.join( + basePath, + 'modules', + module, + 'config.ts' + ); + if (!fs.existsSync(configFilePath)) { + const data = `import { ModuleConfig } from '@cool-midway/core'; + +/** + * 模块配置 + */ +export default () => { + return { + // 模块名称 + name: 'xxx', + // 模块描述 + description: 'xxx', + // 中间件,只对本模块有效 + middlewares: [], + // 中间件,全局有效 + globalMiddlewares: [], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 0, + } as ModuleConfig; +}; +`; + await this.createFile(configFilePath, data); + } + } + + /** + * 找到文件名 + * @param controller + * @returns + */ + async fileName(controller: string) { + const regex = /import\s*{\s*\w+\s*}\s*from\s*'[^']*\/([\w-]+)';/; + const match = regex.exec(controller); + + if (match && match.length > 1) { + return match[1]; + } + + return null; + } + + /** + * 创建文件 + * @param filePath + * @param content + */ + async createFile(filePath: string, content: string) { + const folderPath = pathUtil.dirname(filePath); + if (!fs.existsSync(folderPath)) { + fs.mkdirSync(folderPath, { recursive: true }); + } + fs.writeFileSync(filePath, content); + } + + /** + * 导出菜单 + * @param ids + * @returns + */ + async export(ids: number[]) { + const result: any[] = []; + const menus = await this.baseSysMenuEntity.findBy({ id: In(ids) }); + + // 递归取出子菜单 + const getChildMenus = (parentId: number): any[] => { + const children = _.remove(menus, e => e.parentId == parentId); + children.forEach(child => { + child.childMenus = getChildMenus(child.id); + // 删除不需要的字段 + delete child.id; + delete child.createTime; + delete child.updateTime; + delete child.parentId; + }); + return children; + }; + + // lodash取出父级菜单(parentId为 null), 并从menus 删除 + const parentMenus = _.remove(menus, e => { + return e.parentId == null; + }); + + // 对于每个父级菜单,获取它的子菜单 + parentMenus.forEach(parent => { + parent.childMenus = getChildMenus(parent.id); + // 删除不需要的字段 + delete parent.id; + delete parent.createTime; + delete parent.updateTime; + delete parent.parentId; + + result.push(parent); + }); + + return result; + } + + /** + * 导入 + * @param menus + */ + async import(menus: any[]) { + // 递归保存子菜单 + const saveChildMenus = async (parentMenu: any, parentId: number | null) => { + const children = parentMenu.childMenus || []; + for (let child of children) { + const childData = { ...child, parentId: parentId }; // 保持与数据库的parentId字段的一致性 + delete childData.childMenus; // 删除childMenus属性,因为我们不想将它保存到数据库中 + + // 保存子菜单并获取其ID,以便为其子菜单设置parentId + const savedChild = await this.baseSysMenuEntity.save(childData); + + if (!_.isEmpty(child.childMenus)) { + await saveChildMenus(child, savedChild.id); + } + } + }; + + for (let menu of menus) { + const menuData = { ...menu }; + delete menuData.childMenus; // 删除childMenus属性,因为我们不想将它保存到数据库中 + + // 保存主菜单并获取其ID + const savedMenu = await this.baseSysMenuEntity.save(menuData); + + if (menu.childMenus && menu.childMenus.length > 0) { + await saveChildMenus(menu, savedMenu.id); + } + } + } +} diff --git a/src/modules/base/service/sys/param.ts b/src/modules/base/service/sys/param.ts new file mode 100644 index 0000000..7e75a26 --- /dev/null +++ b/src/modules/base/service/sys/param.ts @@ -0,0 +1,91 @@ +import { Inject, InjectClient, Provide } from '@midwayjs/decorator'; +import { BaseService, CoolCommException } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Not, Repository } from 'typeorm'; +import { BaseSysParamEntity } from '../../entity/sys/param'; +import { CachingFactory, MidwayCache } from '@midwayjs/cache-manager'; + +/** + * 参数配置 + */ +@Provide() +export class BaseSysParamService extends BaseService { + @InjectEntityModel(BaseSysParamEntity) + baseSysParamEntity: Repository; + + @InjectClient(CachingFactory, 'default') + midwayCache: MidwayCache; + + /** + * 根据key获得对应的参数 + * @param key + */ + async dataByKey(key) { + let result: any = await this.midwayCache.get(`param:${key}`); + if (!result) { + result = await this.baseSysParamEntity.findOneBy({ keyName: key }); + this.midwayCache.set(`param:${key}`, result); + } + if (result) { + if (result.dataType == 0) { + try { + return JSON.parse(result.data); + } catch (error) { + return result.data; + } + } + if (result.dataType == 1) { + return result.data; + } + if (result.dataType == 2) { + return result.data.split(','); + } + } + return; + } + + /** + * 根据key获得对应的网页数据 + * @param key + */ + async htmlByKey(key) { + let html = '@title@content'; + let result: any = await this.midwayCache.get(`param:${key}`); + if (result) { + html = html + .replace('@content', result.data) + .replace('@title', result.name); + } else { + html = html.replace('@content', 'key notfound'); + } + return html; + } + + /** + * 添加或者修改 + * @param param + */ + async addOrUpdate(param: any, type): Promise { + const find = { + keyName: param.keyName, + }; + if (param.id) { + find['id'] = Not(param.id); + } + const check = await this.baseSysParamEntity.findOneBy(find); + if (check) { + throw new CoolCommException('存在相同的keyName'); + } + await super.addOrUpdate(param, type); + } + + /** + * 重新初始化缓存 + */ + async modifyAfter() { + const params = await this.baseSysParamEntity.find(); + for (const param of params) { + await this.midwayCache.set(`param:${param.keyName}`, param); + } + } +} diff --git a/src/modules/base/service/sys/perms.ts b/src/modules/base/service/sys/perms.ts new file mode 100644 index 0000000..fc64f21 --- /dev/null +++ b/src/modules/base/service/sys/perms.ts @@ -0,0 +1,90 @@ +import { Inject, InjectClient, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { BaseSysMenuService } from './menu'; +import { BaseSysRoleService } from './role'; +import { BaseSysDepartmentService } from './department'; +import { Context } from '@midwayjs/koa'; +import { CachingFactory, MidwayCache } from '@midwayjs/cache-manager'; +import { BaseSysRoleEntity } from '../../entity/sys/role'; +import { In, Repository } from 'typeorm'; +import { InjectEntityModel } from '@midwayjs/typeorm'; + +/** + * 权限 + */ +@Provide() +export class BaseSysPermsService extends BaseService { + @InjectClient(CachingFactory, 'default') + midwayCache: MidwayCache; + + @Inject() + baseSysMenuService: BaseSysMenuService; + + @Inject() + baseSysRoleService: BaseSysRoleService; + + @Inject() + baseSysDepartmentService: BaseSysDepartmentService; + + @InjectEntityModel(BaseSysRoleEntity) + baseSysRoleEntity: Repository; + + @Inject() + ctx: Context; + base: any; + + /** + * 刷新权限 + * @param userId 用户ID + */ + async refreshPerms(userId) { + const roleIds = await this.baseSysRoleService.getByUser(userId); + const perms = await this.baseSysMenuService.getPerms(roleIds); + await this.midwayCache.set(`admin:perms:${userId}`, perms); + // 更新部门权限 + const departments = await this.baseSysDepartmentService.getByRoleIds( + roleIds, + await this.isAdmin(roleIds) + ); + await this.midwayCache.set(`admin:department:${userId}`, departments); + } + + /** + * 根据角色判断是不是超管 + * @param roleIds + */ + async isAdmin(roleIds: number[]) { + const roles = await this.baseSysRoleEntity.findBy({ id: In(roleIds) }); + const roleLabels = roles.map(item => item.label); + return roleLabels.includes('admin'); + } + + /** + * 获得权限菜单 + * @param roleIds + */ + async permmenu(roleIds: number[]) { + const perms = await this.baseSysMenuService.getPerms(roleIds); + const menus = await this.baseSysMenuService.getMenus( + roleIds, + this.ctx.admin.username === 'admin' + ); + return { perms, menus }; + } + + /** + * 根据用户ID获得部门权限 + * @param userId + * @return 部门ID数组 + */ + async departmentIds(userId: number) { + const department: any = await this.midwayCache.get( + `admin:department:${userId}` + ); + if (department) { + return department; + } else { + return []; + } + } +} diff --git a/src/modules/base/service/sys/role.ts b/src/modules/base/service/sys/role.ts new file mode 100644 index 0000000..e013b52 --- /dev/null +++ b/src/modules/base/service/sys/role.ts @@ -0,0 +1,136 @@ +import { Inject, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseSysRoleEntity } from '../../entity/sys/role'; +import { BaseSysUserRoleEntity } from '../../entity/sys/user_role'; +import * as _ from 'lodash'; +import { BaseSysRoleMenuEntity } from '../../entity/sys/role_menu'; +import { BaseSysRoleDepartmentEntity } from '../../entity/sys/role_department'; +import { BaseSysPermsService } from './perms'; +import { Brackets } from 'typeorm'; + +/** + * 角色 + */ +@Provide() +export class BaseSysRoleService extends BaseService { + @InjectEntityModel(BaseSysRoleEntity) + baseSysRoleEntity: Repository; + + @InjectEntityModel(BaseSysUserRoleEntity) + baseSysUserRoleEntity: Repository; + + @InjectEntityModel(BaseSysRoleMenuEntity) + baseSysRoleMenuEntity: Repository; + + @InjectEntityModel(BaseSysRoleDepartmentEntity) + baseSysRoleDepartmentEntity: Repository; + + @Inject() + baseSysPermsService: BaseSysPermsService; + + @Inject() + ctx; + + /** + * 根据用户ID获得所有用户角色 + * @param userId + */ + async getByUser(userId: number): Promise { + const userRole = await this.baseSysUserRoleEntity.findBy({ userId }); + if (!_.isEmpty(userRole)) { + return userRole.map(e => { + return e.roleId; + }); + } + return []; + } + + /** + * + * @param param + */ + async modifyAfter(param) { + if (param.id) { + this.updatePerms(param.id, param.menuIdList, param.departmentIdList); + } + } + + /** + * 更新权限 + * @param roleId + * @param menuIdList + * @param departmentIds + */ + async updatePerms(roleId, menuIdList?, departmentIds = []) { + // 更新菜单权限 + await this.baseSysRoleMenuEntity.delete({ roleId }); + await Promise.all( + menuIdList.map(async e => { + return await this.baseSysRoleMenuEntity.save({ roleId, menuId: e }); + }) + ); + // 更新部门权限 + await this.baseSysRoleDepartmentEntity.delete({ roleId }); + await Promise.all( + departmentIds.map(async e => { + return await this.baseSysRoleDepartmentEntity.save({ + roleId, + departmentId: e, + }); + }) + ); + // 刷新权限 + const userRoles = await this.baseSysUserRoleEntity.findBy({ roleId }); + for (const userRole of userRoles) { + await this.baseSysPermsService.refreshPerms(userRole.userId); + } + } + + /** + * 角色信息 + * @param id + */ + async info(id) { + const info = await this.baseSysRoleEntity.findOneBy({ id }); + if (info) { + const menus = await this.baseSysRoleMenuEntity.findBy( + id !== 1 ? { roleId: id } : {} + ); + const menuIdList = menus.map(e => { + return parseInt(e.menuId + ''); + }); + const departments = await this.baseSysRoleDepartmentEntity.findBy( + id !== 1 ? { roleId: id } : {} + ); + const departmentIdList = departments.map(e => { + return parseInt(e.departmentId + ''); + }); + return { + ...info, + menuIdList, + departmentIdList, + }; + } + return {}; + } + + async list() { + return this.baseSysRoleEntity + .createQueryBuilder('a') + .where( + new Brackets(qb => { + qb.where('a.id !=:id', { id: 1 }); // 超级管理员的角色不展示 + // 如果不是超管,只能看到自己新建的或者自己有的角色 + if (this.ctx.admin.username !== 'admin') { + qb.andWhere('(a.userId=:userId or a.id in (:...roleId))', { + userId: this.ctx.admin.userId, + roleId: this.ctx.admin.roleIds, + }); + } + }) + ) + .getMany(); + } +} diff --git a/src/modules/base/service/sys/user.ts b/src/modules/base/service/sys/user.ts new file mode 100644 index 0000000..daa0280 --- /dev/null +++ b/src/modules/base/service/sys/user.ts @@ -0,0 +1,235 @@ +import { Inject, InjectClient, Provide } from '@midwayjs/decorator'; +import { BaseService, CoolCommException } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Equal, In, Repository } from 'typeorm'; +import { BaseSysUserEntity } from '../../entity/sys/user'; +import { BaseSysPermsService } from './perms'; +import * as _ from 'lodash'; +import { BaseSysUserRoleEntity } from '../../entity/sys/user_role'; +import * as md5 from 'md5'; +import { BaseSysDepartmentEntity } from '../../entity/sys/department'; +import { CachingFactory, MidwayCache } from '@midwayjs/cache-manager'; + +/** + * 系统用户 + */ +@Provide() +export class BaseSysUserService extends BaseService { + @InjectEntityModel(BaseSysUserEntity) + baseSysUserEntity: Repository; + + @InjectEntityModel(BaseSysUserRoleEntity) + baseSysUserRoleEntity: Repository; + + @InjectEntityModel(BaseSysDepartmentEntity) + baseSysDepartmentEntity: Repository; + + @InjectClient(CachingFactory, 'default') + midwayCache: MidwayCache; + + @Inject() + baseSysPermsService: BaseSysPermsService; + + @Inject() + ctx; + + /** + * 分页查询 + * @param query + */ + async page(query) { + const { keyWord, status, departmentIds = [] } = query; + const permsDepartmentArr = await this.baseSysPermsService.departmentIds( + this.ctx.admin.userId + ); // 部门权限 + const sql = ` + SELECT + a.id,a.name,a.nickName,a.headImg,a.email,a.remark,a.status,a.createTime,a.updateTime,a.username,a.phone,a.departmentId, + b.name as "departmentName" + FROM + base_sys_user a + LEFT JOIN base_sys_department b on a.departmentId = b.id + WHERE 1 = 1 + ${this.setSql( + !_.isEmpty(departmentIds), + 'and a.departmentId in (?)', + [departmentIds] + )} + ${this.setSql(status, 'and a.status = ?', [status])} + ${this.setSql(keyWord, 'and (a.name LIKE ? or a.username LIKE ?)', [ + `%${keyWord}%`, + `%${keyWord}%`, + ])} + ${this.setSql(true, 'and a.username != ?', ['admin'])} + ${this.setSql( + this.ctx.admin.username !== 'admin', + 'and a.departmentId in (?)', + [!_.isEmpty(permsDepartmentArr) ? permsDepartmentArr : [null]] + )} `; + const result = await this.sqlRenderPage(sql, query); + // 匹配角色 + if (!_.isEmpty(result.list)) { + const userIds = result.list.map(e => e.id); + const roles = await this.nativeQuery( + 'SELECT b.name, a.userId FROM base_sys_user_role a LEFT JOIN base_sys_role b ON a.roleId = b.id WHERE a.userId in (?) ', + [userIds] + ); + result.list.forEach(e => { + e['roleName'] = roles + .filter(role => role.userId == e.id) + .map(role => role.name) + .join(','); + }); + } + return result; + } + + /** + * 移动部门 + * @param departmentId + * @param userIds + */ + async move(departmentId, userIds) { + await this.baseSysUserEntity.update({ id: In(userIds) }, { departmentId }); + } + + /** + * 获得个人信息 + */ + async person(userId: number) { + const info = await this.baseSysUserEntity.findOneBy({ + id: Equal(userId), + }); + delete info?.password; + return info; + } + + /** + * 更新用户角色关系 + * @param user + */ + async updateUserRole(user) { + if (_.isEmpty(user.roleIdList)) { + return; + } + if (user.username === 'admin') { + throw new CoolCommException('非法操作~'); + } + await this.baseSysUserRoleEntity.delete({ userId: user.id }); + if (user.roleIdList) { + for (const roleId of user.roleIdList) { + await this.baseSysUserRoleEntity.save({ userId: user.id, roleId }); + } + } + await this.baseSysPermsService.refreshPerms(user.id); + } + + /** + * 新增 + * @param param + */ + async add(param) { + const exists = await this.baseSysUserEntity.findOneBy({ + username: param.username, + }); + if (!_.isEmpty(exists)) { + throw new CoolCommException('用户名已经存在~'); + } + param.password = md5(param.password); + await this.baseSysUserEntity.save(param); + await this.updateUserRole(param); + return param.id; + } + + /** + * 根据ID获得信息 + * @param id + */ + public async info(id) { + const info = await this.baseSysUserEntity.findOneBy({ id }); + const userRoles = await this.nativeQuery( + 'select a.roleId from base_sys_user_role a where a.userId = ?', + [id] + ); + const department = await this.baseSysDepartmentEntity.findOneBy({ + id: info.departmentId, + }); + if (info) { + delete info.password; + if (userRoles) { + info.roleIdList = userRoles.map(e => { + return parseInt(e.roleId); + }); + } + } + delete info.password; + if (department) { + info.departmentName = department.name; + } + return info; + } + + /** + * 修改个人信息 + * @param param + */ + public async personUpdate(param) { + param.id = this.ctx.admin.userId; + if (!_.isEmpty(param.password)) { + param.password = md5(param.password); + const oldPassword = md5(param.oldPassword); + const userInfo = await this.baseSysUserEntity.findOneBy({ id: param.id }); + if (!userInfo) { + throw new CoolCommException('用户不存在'); + } + if (oldPassword !== userInfo.password) { + throw new CoolCommException('原密码错误'); + } + param.passwordV = userInfo.passwordV + 1; + await this.midwayCache.set( + `admin:passwordVersion:${param.id}`, + param.passwordV + ); + } else { + delete param.password; + } + await this.baseSysUserEntity.save(param); + } + + /** + * 修改 + * @param param 数据 + */ + async update(param) { + if (param.id && param.username === 'admin') { + throw new CoolCommException('非法操作~'); + } + if (!_.isEmpty(param.password)) { + param.password = md5(param.password); + const userInfo = await this.baseSysUserEntity.findOneBy({ id: param.id }); + if (!userInfo) { + throw new CoolCommException('用户不存在'); + } + param.passwordV = userInfo.passwordV + 1; + await this.midwayCache.set( + `admin:passwordVersion:${param.id}`, + param.passwordV + ); + } else { + delete param.password; + } + if (param.status === 0) { + await this.forbidden(param.id); + } + await this.baseSysUserEntity.save(param); + await this.updateUserRole(param); + } + + /** + * 禁用用户 + * @param userId + */ + async forbidden(userId) { + await this.midwayCache.del(`admin:token:${userId}`); + } +} diff --git a/src/modules/count/config.ts b/src/modules/count/config.ts new file mode 100644 index 0000000..6412d7a --- /dev/null +++ b/src/modules/count/config.ts @@ -0,0 +1,19 @@ +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 模块配置 + */ +export default () => { + return { + // 模块名称 + name: '统计分析', + // 模块描述 + description: '订单、商品、用户等统计', + // 中间件,只对本模块有效 + middlewares: [], + // 中间件,全局有效 + globalMiddlewares: [], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 0, + } as ModuleConfig; +}; diff --git a/src/modules/count/controller/admin/home.ts b/src/modules/count/controller/admin/home.ts new file mode 100644 index 0000000..f50bd08 --- /dev/null +++ b/src/modules/count/controller/admin/home.ts @@ -0,0 +1,69 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { Body, Inject, Post } from '@midwayjs/core'; +import { CountUserService } from '../../service/user'; +import { CountOrderService } from '../../service/order'; +import { CountGoodsService } from '../../service/goods'; + +/** + * 首页统计 + */ +@CoolController() +export class AdminCountHomeController extends BaseController { + @Inject() + countUserService: CountUserService; + + @Inject() + countOrderService: CountOrderService; + + @Inject() + countGoodsService: CountGoodsService; + + @Post('/userSummary', { summary: '用户概况' }) + async userSummary() { + return this.ok(await this.countUserService.summary()); + } + + @Post('/userChart', { summary: '用户图表' }) + async userChart( + // 天数 + @Body('dayCount') dayCount: number + ) { + return this.ok(await this.countUserService.chart(dayCount)); + } + + @Post('/orderSummary', { summary: '订单概况' }) + async orderSummary( + // 类型 count-数量 amount-金额 + @Body('type') type: 'count' | 'amount' + ) { + return this.ok(await this.countOrderService.summary(type)); + } + + @Post('/orderChart', { summary: '订单图表' }) + async orderChart( + // 天数 + @Body('dayCount') dayCount: number, + // 类型 count-数量 amount-金额 + @Body('type') type: 'count' | 'amount' + ) { + return this.ok(await this.countOrderService.chart(dayCount, type)); + } + + @Post('/goodsRank', { summary: '商品排行' }) + async goodsRank( + // 类型 count-数量 amount-金额 + @Body('type') type: 'count' | 'amount', + // 条数 + @Body('limit') limit: number + ) { + return this.ok(await this.countGoodsService.rank(type, limit)); + } + + @Post('/goodsCategory', { summary: '商品分类' }) + async goodsCategory( + // 类型 count-数量 amount-金额 + @Body('type') type: 'count' | 'amount' + ) { + return this.ok(await this.countGoodsService.category(type)); + } +} diff --git a/src/modules/count/service/comm.ts b/src/modules/count/service/comm.ts new file mode 100644 index 0000000..ef286e2 --- /dev/null +++ b/src/modules/count/service/comm.ts @@ -0,0 +1,71 @@ +import { Init, Inject, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import * as moment from 'moment'; +import { Utils } from '../../../comm/utils'; + +/** + * 通用 + */ +@Provide() +export class CountCommService extends BaseService { + @Inject() + utils: Utils; + + /** + * 获得时间范围 + * @param unit + */ + getTimeRange(unit: 'day' | 'week' | 'month' | 'year') { + let start, end; + if (unit === 'week') { + // 周的开始时间加一天,以匹配您的需求 + start = moment() + .startOf(unit) + .add(1, 'days') + .format('YYYY-MM-DD HH:mm:ss'); + } else { + start = moment().startOf(unit).format('YYYY-MM-DD HH:mm:ss'); + } + end = moment().endOf(unit).format('YYYY-MM-DD HH:mm:ss'); + return { start, end }; + } + + /** + * 图表 + * @param tableName + * @param dayCount + * @param column 统计列 + * @returns + */ + async chart(tableName: string, dayCount: number, column = 'count(a.id)') { + const result = { + datas: [], + dates: [], + }; + const dates = this.utils.getRecentlyDates(dayCount); + const count = await this.nativeQuery( + `SELECT + ${column} as total, + LEFT(a.createTime, 10) AS time + FROM + ${tableName} a + WHERE + DATE_SUB( CURDATE( ), INTERVAL ? DAY ) <= a.createTime + GROUP BY + LEFT(a.createTime, 10)`, + [dayCount] + ); + dates.forEach(date => { + let data = 0; + for (const item of count) { + if (date == item.time) { + data = item.total; + break; + } + } + result.dates.push(date); + result.datas.push(data); + }); + return result; + } +} diff --git a/src/modules/count/service/goods.ts b/src/modules/count/service/goods.ts new file mode 100644 index 0000000..c3689ab --- /dev/null +++ b/src/modules/count/service/goods.ts @@ -0,0 +1,55 @@ +import { Init, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { OrderGoodsEntity } from '../../order/entity/goods'; + +/** + * 商品统计 + */ +@Provide() +export class CountGoodsService extends BaseService { + @InjectEntityModel(OrderGoodsEntity) + orderGoodsEntity: Repository; + + /** + * 销售排行 + * @param type 类型 count-数量 amount-金额 + * @param limit 条数 + */ + async rank(type: 'count' | 'amount', limit = 10) { + const sql = `SELECT * FROM ( + SELECT + a.goodsId, + b.title, + b.mainPic, + ${type === 'count' ? 'SUM(a.count)' : 'SUM(a.price * a.count)'} AS total + FROM + order_goods a + LEFT JOIN goods_info b ON a.goodsId = b.id + GROUP BY + a.goodsId + ORDER BY + total DESC + ) a LIMIT ${parseInt(limit.toString())}`; + return this.nativeQuery(sql); + } + + /** + * 分类统计 + * @param type 类型 count-数量 amount-金额 + */ + async category(type: 'count' | 'amount') { + const sql = `SELECT + b.typeId, + c.name AS typeName, + ${type === 'count' ? 'SUM(a.count)' : 'SUM(a.price * a.count)'} AS total + FROM + order_goods a + LEFT JOIN goods_info b ON a.goodsId = b.id + LEFT JOIN goods_type c ON b.typeId = c.id + GROUP BY + b.typeId`; + return this.nativeQuery(sql); + } +} diff --git a/src/modules/count/service/order.ts b/src/modules/count/service/order.ts new file mode 100644 index 0000000..47b20ff --- /dev/null +++ b/src/modules/count/service/order.ts @@ -0,0 +1,94 @@ +import { Init, Inject, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { OrderInfoEntity } from '../../order/entity/info'; +import { CountCommService } from './comm'; + +/** + * 订单统计 + */ +@Provide() +export class CountOrderService extends BaseService { + @InjectEntityModel(OrderInfoEntity) + orderInfoEntity: Repository; + + @Inject() + countCommService: CountCommService; + + /** + * 概况 + * @param type 类型 count-数量 amount-金额 + * @returns + */ + async summary(type: 'count' | 'amount' = 'count') { + // 定义一个函数来创建查询 + const createQuery = async (start, end) => { + if (type === 'count') { + return await this.orderInfoEntity + .createQueryBuilder('a') + .where('a.createTime >= :start', { start }) + .andWhere('a.createTime <= :end', { end }) + .getCount(); + } + if (type === 'amount') { + const result = await this.orderInfoEntity + .createQueryBuilder('a') + .select('SUM(a.price)', 'amount') + .where('a.createTime >= :start', { start }) + .andWhere('a.createTime <= :end', { end }) + .getRawOne(); + return result.amount || 0; + } + }; + + // 总数 + const total = await (type == 'amount' + ? this.orderInfoEntity.sum('price') + : this.orderInfoEntity.count()); + + // 获取今天的时间范围 + const { start: todayStart, end: todayEnd } = + this.countCommService.getTimeRange('day'); + // 今天 + const today = await createQuery(todayStart, todayEnd); + + // 获取本周的时间范围 + const { start: weekStart, end: weekEnd } = + this.countCommService.getTimeRange('week'); + // 本周 + const week = await createQuery(weekStart, weekEnd); + + // 获取本月的时间范围 + const { start: monthStart, end: monthEnd } = + this.countCommService.getTimeRange('month'); + // 本月 + const month = await createQuery(monthStart, monthEnd); + + // 获取年的时间范围 + const { start: yearStart, end: yearEnd } = + this.countCommService.getTimeRange('year'); + // 今年 + const year = await createQuery(yearStart, yearEnd); + + return { total, today, week, month, year }; + } + + /** + * 图表 + * @param dayCount 最近多少天 + * @param type 类型 count-数量 amount-金额 + */ + async chart(dayCount = 30, type: 'count' | 'amount' = 'count') { + if (type == 'count') { + return await this.countCommService.chart('order_info', dayCount); + } + if (type == 'amount') { + return await this.countCommService.chart( + 'order_info', + dayCount, + 'SUM(a.price)' + ); + } + } +} diff --git a/src/modules/count/service/user.ts b/src/modules/count/service/user.ts new file mode 100644 index 0000000..5d62e53 --- /dev/null +++ b/src/modules/count/service/user.ts @@ -0,0 +1,69 @@ +import { Init, Inject, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserInfoEntity } from '../../user/entity/info'; +import { CountCommService } from './comm'; + +/** + * 用户统计 + */ +@Provide() +export class CountUserService extends BaseService { + @InjectEntityModel(UserInfoEntity) + userInfoEntity: Repository; + + @Inject() + countCommService: CountCommService; + + /** + * 概况 + */ + async summary() { + // 定义一个函数来创建查询 + const createQuery = async (start, end) => { + return await this.userInfoEntity + .createQueryBuilder('a') + .where('a.createTime >= :start', { start }) + .andWhere('a.createTime <= :end', { end }) + .getCount(); + }; + + // 总数 + const total = await this.userInfoEntity.count(); + + // 获取今天的时间范围 + const { start: todayStart, end: todayEnd } = + this.countCommService.getTimeRange('day'); + // 今天 + const today = await createQuery(todayStart, todayEnd); + + // 获取本周的时间范围 + const { start: weekStart, end: weekEnd } = + this.countCommService.getTimeRange('week'); + // 本周 + const week = await createQuery(weekStart, weekEnd); + + // 获取本月的时间范围 + const { start: monthStart, end: monthEnd } = + this.countCommService.getTimeRange('month'); + // 本月 + const month = await createQuery(monthStart, monthEnd); + + // 获取年的时间范围 + const { start: yearStart, end: yearEnd } = + this.countCommService.getTimeRange('year'); + // 今年 + const year = await createQuery(yearStart, yearEnd); + + return { total, today, week, month, year }; + } + + /** + * 图表 + * @param dayCount 最近多少天 + */ + async chart(dayCount = 30) { + return await this.countCommService.chart('user_info', dayCount); + } +} diff --git a/src/modules/cs/config.ts b/src/modules/cs/config.ts new file mode 100644 index 0000000..78d2201 --- /dev/null +++ b/src/modules/cs/config.ts @@ -0,0 +1,19 @@ +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 模块配置 + */ +export default () => { + return { + // 模块名称 + name: '客服模块', + // 模块描述 + description: '客服系统', + // 中间件,只对本模块有效 + middlewares: [], + // 中间件,全局有效 + globalMiddlewares: [], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 0, + } as ModuleConfig; +}; diff --git a/src/modules/cs/controller/admin/msg.ts b/src/modules/cs/controller/admin/msg.ts new file mode 100644 index 0000000..19740c5 --- /dev/null +++ b/src/modules/cs/controller/admin/msg.ts @@ -0,0 +1,58 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { CsMsgEntity } from '../../entity/msg'; +import { UserInfoEntity } from '../../../user/entity/info'; +import { BaseSysUserEntity } from '../../../base/entity/sys/user'; +import { CsMsgService } from '../../service/msg'; +import { Body, Get, Inject, Post } from '@midwayjs/core'; + +/** + * 消息 + */ +@CoolController({ + api: ['page'], + entity: CsMsgEntity, + service: CsMsgService, + pageQueryOp: { + select: [ + 'a.*', + 'b.nickName', + 'b.avatarUrl', + 'c.name as adminUserName', + 'c.headImg as adminUserHeadImg', + ], + join: [ + { + entity: UserInfoEntity, + alias: 'b', + condition: 'a.userId = b.id and a.type = 0', + }, + { + entity: BaseSysUserEntity, + alias: 'c', + condition: 'a.userId = c.id and a.type = 1', + }, + ], + where: ctx => { + const { sessionId } = ctx.request.body; + return [['a.sessionId = :sessionId', { sessionId }]]; + }, + }, +}) +export class AdminCsMsgController extends BaseController { + @Inject() + ctx; + + @Inject() + csMsgService: CsMsgService; + + @Get('/unreadCount', { summary: '未读消息数' }) + async unreadCount() { + return this.ok(await this.csMsgService.unreadCount(null, 0)); + } + + @Post('/read', { summary: '标记已读' }) + async read(@Body('msgIds') msgIds: number[]) { + this.csMsgService.read(msgIds); + return this.ok(); + } +} diff --git a/src/modules/cs/controller/admin/session.ts b/src/modules/cs/controller/admin/session.ts new file mode 100644 index 0000000..23817ca --- /dev/null +++ b/src/modules/cs/controller/admin/session.ts @@ -0,0 +1,22 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { CsSessionEntity } from '../../entity/session'; +import { UserInfoEntity } from '../../../user/entity/info'; + +/** + * 客服会话 + */ +@CoolController({ + api: ['page'], + entity: CsSessionEntity, + pageQueryOp: { + select: ['a.*', 'b.nickName', 'b.avatarUrl'], + join: [ + { + entity: UserInfoEntity, + alias: 'b', + condition: 'a.userId = b.id', + }, + ], + }, +}) +export class Controller extends BaseController {} diff --git a/src/modules/cs/controller/app/msg.ts b/src/modules/cs/controller/app/msg.ts new file mode 100644 index 0000000..9aea144 --- /dev/null +++ b/src/modules/cs/controller/app/msg.ts @@ -0,0 +1,62 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { CsMsgEntity } from '../../entity/msg'; +import { CsSessionEntity } from '../../entity/session'; +import { BaseSysUserEntity } from '../../../base/entity/sys/user'; +import { UserInfoEntity } from '../../../user/entity/info'; +import { Body, Get, Inject, Post } from '@midwayjs/core'; +import { CsMsgService } from '../../service/msg'; + +/** + * 消息 + */ +@CoolController({ + api: ['page'], + entity: CsMsgEntity, + pageQueryOp: { + select: [ + 'a.*', + 'b.nickName', + 'b.avatarUrl', + 'c.name as adminUserName', + 'c.headImg as adminUserHeadImg', + ], + join: [ + { + entity: UserInfoEntity, + alias: 'b', + condition: 'a.userId = b.id and a.type = 0', + }, + { + entity: BaseSysUserEntity, + alias: 'c', + condition: 'a.userId = c.id and a.type = 1', + }, + { + entity: CsSessionEntity, + alias: 'd', + condition: 'a.sessionId = d.id', + }, + ], + where: ctx => { + return [['d.userId = :userId', { userId: ctx.user?.id }]]; + }, + }, +}) +export class AppCsMsgController extends BaseController { + @Inject() + ctx; + + @Inject() + csMsgService: CsMsgService; + + @Get('/unreadCount', { summary: '未读消息数' }) + async unreadCount() { + return this.ok(await this.csMsgService.unreadCount(this.ctx.user?.id, 1)); + } + + @Post('/read', { summary: '标记已读' }) + async read(@Body('msgIds') msgIds: number[]) { + this.csMsgService.read(msgIds); + return this.ok(); + } +} diff --git a/src/modules/cs/controller/app/session.ts b/src/modules/cs/controller/app/session.ts new file mode 100644 index 0000000..eda87a3 --- /dev/null +++ b/src/modules/cs/controller/app/session.ts @@ -0,0 +1,25 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { Get, Inject, Post } from '@midwayjs/core'; +import { CsSessionService } from '../../service/session'; + +/** + * 客服会话 + */ +@CoolController() +export class Controller extends BaseController { + @Inject() + csSessionService: CsSessionService; + + @Inject() + ctx; + + @Get('/detail', { summary: '会话详情' }) + async detail() { + return this.ok(await this.csSessionService.detail(this.ctx.user?.id)); + } + + @Post('/create', { summary: '创建会话' }) + async create() { + return this.ok(await this.csSessionService.create(this.ctx.user?.id)); + } +} diff --git a/src/modules/cs/entity/conn.ts b/src/modules/cs/entity/conn.ts new file mode 100644 index 0000000..7577b57 --- /dev/null +++ b/src/modules/cs/entity/conn.ts @@ -0,0 +1,18 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 客服连接 + */ +@Entity('cs_conn') +export class CsConnEntity extends BaseEntity { + @Index() + @Column({ comment: '用户ID' }) + userId: number; + + @Column({ comment: '连接ID' }) + connId: string; + + @Column({ comment: '类型 0-客户 1-后台', default: 0 }) + type: number; +} diff --git a/src/modules/cs/entity/msg.ts b/src/modules/cs/entity/msg.ts new file mode 100644 index 0000000..c879769 --- /dev/null +++ b/src/modules/cs/entity/msg.ts @@ -0,0 +1,36 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 客服消息 + */ +@Entity('cs_msg') +export class CsMsgEntity extends BaseEntity { + @Index() + @Column({ comment: '用户ID' }) + userId: number; + + @Index() + @Column({ comment: '会话ID' }) + sessionId: number; + + @Column({ comment: '消息内容', type: 'json' }) + content: { + type: + | 'text' + | 'image' + | 'voice' + | 'video' + | 'file' + | 'link' + | 'location' + | 'emoji'; + data: string; + }; + + @Column({ comment: '类型 0-反馈 1-回复', default: 0 }) + type: number; + + @Column({ comment: '状态 0-未读 1-已读', default: 0 }) + status: number; +} diff --git a/src/modules/cs/entity/session.ts b/src/modules/cs/entity/session.ts new file mode 100644 index 0000000..8a83685 --- /dev/null +++ b/src/modules/cs/entity/session.ts @@ -0,0 +1,19 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; +import { CsMsgEntity } from './msg'; + +/** + * 客服会话 + */ +@Entity('cs_session') +export class CsSessionEntity extends BaseEntity { + @Index() + @Column({ comment: '用户ID' }) + userId: number; + + @Column({ comment: '最后一条消息', nullable: true, type: 'json' }) + lastMsg: CsMsgEntity; + + @Column({ comment: '客服未读消息数', default: 0 }) + adminUnreadCount: number; +} diff --git a/src/modules/cs/menu.json b/src/modules/cs/menu.json new file mode 100644 index 0000000..d2828cd --- /dev/null +++ b/src/modules/cs/menu.json @@ -0,0 +1,27 @@ +[ + { + "name": "客服模块", + "router": null, + "perms": null, + "type": 0, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": false, + "childMenus": [ + { + "name": "通用", + "router": null, + "perms": "cs:session:page,cs:session:update,cs:session:info,cs:session:list,cs:session:add,cs:msg:unreadCount,cs:msg:page,cs:msg:update,cs:msg:info,cs:msg:list,cs:msg:add", + "type": 2, + "icon": null, + "orderNum": 0, + "viewPath": null, + "keepAlive": true, + "isShow": true, + "childMenus": [] + } + ] + } +] \ No newline at end of file diff --git a/src/modules/cs/service/conn.ts b/src/modules/cs/service/conn.ts new file mode 100644 index 0000000..78ad8ab --- /dev/null +++ b/src/modules/cs/service/conn.ts @@ -0,0 +1,57 @@ +import { Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { CsConnEntity } from '../entity/conn'; +import { CsSessionEntity } from '../entity/session'; + +/** + * 连接 + */ +@Provide() +export class CsConnService extends BaseService { + @InjectEntityModel(CsConnEntity) + csConnEntity: Repository; + + @InjectEntityModel(CsSessionEntity) + csSessionEntity: Repository; + + /** + * 绑定 + * @param isAdmin + * @param userId + * @param connId + */ + async binding(isAdmin: boolean, userId: number, connId: string) { + const info = await this.csConnEntity.findOneBy({ + userId, + type: isAdmin ? 1 : 0, + }); + if (info) { + info.connId = connId; + info.type = isAdmin ? 1 : 0; + await this.csConnEntity.update(info.id, { connId: connId }); + } else { + const entity = new CsConnEntity(); + entity.userId = userId; + entity.connId = connId; + entity.type = isAdmin ? 1 : 0; + await this.csConnEntity.insert(entity); + } + } + + /** + * 获得连接ID + * @param isAdmin + * @param sessionId + */ + async getConnId(isAdmin: boolean, sessionId: number) { + const session = await this.csSessionEntity.findOneBy({ id: sessionId }); + if (!session) return; + const info = await this.csConnEntity.findOneBy({ + userId: session.userId, + type: isAdmin ? 1 : 0, + }); + return info ? info.connId : null; + } +} diff --git a/src/modules/cs/service/msg.ts b/src/modules/cs/service/msg.ts new file mode 100644 index 0000000..a2c81ad --- /dev/null +++ b/src/modules/cs/service/msg.ts @@ -0,0 +1,140 @@ +import { App, Inject, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { CsMsgEntity } from '../entity/msg'; +import { CsSessionEntity } from '../entity/session'; +import { CsConnService } from './conn'; +import { Application as SocketApplication } from '@midwayjs/socketio'; +import * as _ from 'lodash'; +import { UserInfoEntity } from '../../user/entity/info'; +import { BaseSysUserEntity } from '../../base/entity/sys/user'; + +/** + * 消息 + */ +@Provide() +export class CsMsgService extends BaseService { + @InjectEntityModel(CsMsgEntity) + csMsgEntity: Repository; + + @InjectEntityModel(CsSessionEntity) + csSessionEntity: Repository; + + @InjectEntityModel(UserInfoEntity) + userInfoEntity: Repository; + + @InjectEntityModel(BaseSysUserEntity) + baseSysUserEntity: Repository; + + @Inject() + csConnService: CsConnService; + + @App('socketIO') + socketApp: SocketApplication; + + @Inject() + ctx; + + /** + * 消息标记为已读 + * @param msgIds + */ + async read(msgIds: number[]) { + await this.csMsgEntity.update({ id: In(msgIds) }, { status: 1 }); + } + + /** + * 未读消息数 + * @param userId + * @param type + */ + async unreadCount(userId: number, type: number) { + if (userId) { + return this.csMsgEntity.countBy({ userId, type, status: 0 }); + } + return this.csMsgEntity.countBy({ type, status: 0 }); + } + + /** + * 更新客户未读消息数 + * @param sessionId + */ + async updateAdminUnreadCount(sessionId: number) { + const count = await this.csMsgEntity.countBy({ + sessionId, + type: 0, + status: 0, + }); + await this.csSessionEntity.update(sessionId, { adminUnreadCount: count }); + } + + /** + * 分页查询 + * @param query + * @param option + * @param connectionName + */ + async page(query: any, option: any, connectionName?: any) { + const { sessionId } = query; + const result = await super.page(query, option, connectionName); + // 消息置为已读 + const type = this.ctx.user?.id ? 1 : 0; + await this.csMsgEntity.update({ sessionId, type }, { status: 1 }); + // 更新未读消息数 + await this.updateAdminUnreadCount(sessionId); + + return result; + } + + /** + * 消息发送 + * @param msg + */ + async send(msg: any, isAdmin: boolean) { + let session = await this.csSessionEntity.findOneBy({ id: msg.sessionId }); + if (isAdmin) { + msg.type = 1; + } else { + msg.type = 0; + if (!session) { + session = new CsSessionEntity(); + session.userId = msg.userId; + await this.csSessionEntity.insert(session); + } + } + await this.csMsgEntity.insert(msg); + // 更新未读消息数 + await this.updateAdminUnreadCount(msg.sessionId); + // 更新最后一条消息 + await this.csSessionEntity.update(session.id, { lastMsg: msg }); + + // 完善消息内容 + if (msg.type == 0) { + const user = await this.userInfoEntity.findOneBy({ id: msg.userId }); + msg.user = { + userId: user.id, + nickName: user.nickName, + avatarUrl: user.avatarUrl, + }; + } + if (msg.type == 1) { + const user = await this.baseSysUserEntity.findOneBy({ id: msg.userId }); + msg.user = { + userId: user.id, + nickName: user.name, + avatarUrl: user.headImg, + }; + } + // 发送给客户端 + if (isAdmin) { + // 获得连接ID + const connId = await this.csConnService.getConnId(false, msg.sessionId); + // 发送消息 + this.socketApp.of('/cs').to(connId).emit('msg', msg); + } else { + // 广播给所有的后端客服 + this.socketApp.of('/cs').emit('msg', msg); + } + } +} diff --git a/src/modules/cs/service/session.ts b/src/modules/cs/service/session.ts new file mode 100644 index 0000000..29a56be --- /dev/null +++ b/src/modules/cs/service/session.ts @@ -0,0 +1,38 @@ +import { Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { CsSessionEntity } from '../entity/session'; + +/** + * 客服会话 + */ +@Provide() +export class CsSessionService extends BaseService { + @InjectEntityModel(CsSessionEntity) + csSessionEntity: Repository; + + /** + * 会话详情 + * @param userId + */ + async detail(userId: number) { + return await this.csSessionEntity.findOneBy({ userId }); + } + + /** + * 创建会话 + * @param userId + * @returns + */ + async create(userId: number) { + const check = await this.detail(userId); + if (check) { + return check; + } + const session = new CsSessionEntity(); + session.userId = userId; + await this.csSessionEntity.insert(session); + return session; + } +} diff --git a/src/modules/cs/socket/io.ts b/src/modules/cs/socket/io.ts new file mode 100644 index 0000000..ff7b440 --- /dev/null +++ b/src/modules/cs/socket/io.ts @@ -0,0 +1,46 @@ +import { + WSController, + OnWSConnection, + Inject, + OnWSMessage, +} from '@midwayjs/decorator'; +import { CsConnService } from '../service/conn'; +import { CsSocketTokenMiddleware } from './middleware/token'; +import { CsMsgService } from '../service/msg'; +/** + * code-socket + */ +@WSController('/cs') +export class CsSocketIoController { + @Inject() + ctx; + + @Inject() + csConnService: CsConnService; + + @Inject() + csMsgService: CsMsgService; + + // 客户端连接 + @OnWSConnection({ middleware: [CsSocketTokenMiddleware] }) + async onConnectionMethod() { + const { isAdmin, userId } = this.ctx.connData; + await this.csConnService.binding(isAdmin, userId, this.ctx.id); + this.ctx.emit('message', '连接成功'); + } + + // 发送消息 + @OnWSMessage('send', { middleware: [CsSocketTokenMiddleware] }) + async send(data) { + const { isAdmin, userId } = this.ctx.connData; + const { sessionId, content } = data; + await this.csMsgService.send( + { + userId, + sessionId, + content, + }, + isAdmin + ); + } +} diff --git a/src/modules/cs/socket/middleware/token.ts b/src/modules/cs/socket/middleware/token.ts new file mode 100644 index 0000000..42ac237 --- /dev/null +++ b/src/modules/cs/socket/middleware/token.ts @@ -0,0 +1,34 @@ +import { Config, Middleware } from '@midwayjs/core'; +import { NextFunction } from '@midwayjs/socketio'; +import * as jwt from 'jsonwebtoken'; + +@Middleware() +export class CsSocketTokenMiddleware { + @Config('module.user.jwt') + appJwtConfig; + + @Config('module.base.jwt') + adminJwtConfig; + + resolve() { + return async (ctx, next: NextFunction) => { + try { + // 获得连接参数 + const isAdmin = ctx.handshake.auth.isAdmin; + const token = ctx.handshake.auth.token; + const data = jwt.verify( + token, + isAdmin ? this.adminJwtConfig.secret : this.appJwtConfig.secret + ); + ctx.connData = { + userId: isAdmin ? data.userId : data.id, + isAdmin, + }; + } catch (error) { + ctx.emit('sys', '连接失败'); + return; + } + return await next(); + }; + } +} diff --git a/src/modules/dict/config.ts b/src/modules/dict/config.ts new file mode 100644 index 0000000..0c7e579 --- /dev/null +++ b/src/modules/dict/config.ts @@ -0,0 +1,19 @@ +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 模块配置 + */ +export default () => { + return { + // 模块名称 + name: '字典管理', + // 模块描述 + description: '数据字典等', + // 中间件,只对本模块有效 + middlewares: [], + // 中间件,全局有效 + globalMiddlewares: [], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 0, + } as ModuleConfig; +}; diff --git a/src/modules/dict/controller/admin/info.ts b/src/modules/dict/controller/admin/info.ts new file mode 100644 index 0000000..ebe684a --- /dev/null +++ b/src/modules/dict/controller/admin/info.ts @@ -0,0 +1,30 @@ +import { DictInfoEntity } from './../../entity/info'; +import { Body, Inject, Post, Provide } from '@midwayjs/decorator'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { DictInfoService } from '../../service/info'; + +/** + * 字典信息 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: DictInfoEntity, + service: DictInfoService, + listQueryOp: { + fieldEq: ['typeId'], + keyWordLikeFields: ['name'], + addOrderBy: { + createTime: 'ASC', + }, + }, +}) +export class AdminDictInfoController extends BaseController { + @Inject() + dictInfoService: DictInfoService; + + @Post('/data', { summary: '获得字典数据' }) + async data(@Body('types') types: string[] = []) { + return this.ok(await this.dictInfoService.data(types)); + } +} diff --git a/src/modules/dict/controller/admin/type.ts b/src/modules/dict/controller/admin/type.ts new file mode 100644 index 0000000..e31cdd8 --- /dev/null +++ b/src/modules/dict/controller/admin/type.ts @@ -0,0 +1,18 @@ +import { DictTypeEntity } from './../../entity/type'; +import { Provide } from '@midwayjs/decorator'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { DictTypeService } from '../../service/type'; + +/** + * 字典类型 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: DictTypeEntity, + service: DictTypeService, + listQueryOp: { + keyWordLikeFields: ['name'], + }, +}) +export class AdminDictTypeController extends BaseController {} diff --git a/src/modules/dict/controller/app/info.ts b/src/modules/dict/controller/app/info.ts new file mode 100644 index 0000000..b83a849 --- /dev/null +++ b/src/modules/dict/controller/app/info.ts @@ -0,0 +1,26 @@ +import { Body, Inject, Post, Provide } from '@midwayjs/decorator'; +import { + CoolController, + BaseController, + CoolUrlTag, + TagTypes, + CoolTag, +} from '@cool-midway/core'; +import { DictInfoService } from '../../service/info'; + +/** + * 字典信息 + */ +@Provide() +@CoolController() +@CoolUrlTag() +export class AppDictInfoController extends BaseController { + @Inject() + dictInfoService: DictInfoService; + + @CoolTag(TagTypes.IGNORE_TOKEN) + @Post('/data', { summary: '获得字典数据' }) + async data(@Body('types') types: string[] = []) { + return this.ok(await this.dictInfoService.data(types)); + } +} diff --git a/src/modules/dict/db.json b/src/modules/dict/db.json new file mode 100644 index 0000000..d24a33f --- /dev/null +++ b/src/modules/dict/db.json @@ -0,0 +1,324 @@ +{ + "dict_info": [ + { + "id": 31, + "typeId": 21, + "name": "不想要了", + "value": "", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 32, + "typeId": 21, + "name": "商品错选/多选", + "value": "", + "orderNum": 2, + "remark": null, + "parentId": null + }, + { + "id": 33, + "typeId": 21, + "name": "商品无货", + "value": "", + "orderNum": 3, + "remark": null, + "parentId": null + }, + { + "id": 34, + "typeId": 21, + "name": "地址信息填写错误", + "value": "", + "orderNum": 4, + "remark": null, + "parentId": null + }, + { + "id": 35, + "typeId": 21, + "name": "商品降价", + "value": "", + "orderNum": 5, + "remark": null, + "parentId": null + }, + { + "id": 36, + "typeId": 21, + "name": "没用/少用/错用优惠", + "value": "", + "orderNum": 6, + "remark": null, + "parentId": null + }, + { + "id": 37, + "typeId": 21, + "name": "价格高于其他平台", + "value": "", + "orderNum": 7, + "remark": null, + "parentId": null + }, + { + "id": 38, + "typeId": 22, + "name": "安卓", + "value": "0", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 39, + "typeId": 22, + "name": "IOS", + "value": "1", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 40, + "typeId": 23, + "name": "崩溃与错误", + "value": "0", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 41, + "typeId": 23, + "name": "支付问题", + "value": "1", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 42, + "typeId": 23, + "name": "体验不佳", + "value": "2", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 43, + "typeId": 23, + "name": "功能缺失", + "value": "3", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 44, + "typeId": 23, + "name": "其他", + "value": "4", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 45, + "typeId": 24, + "name": "崩溃与错误", + "value": "0", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 46, + "typeId": 24, + "name": "支付问题", + "value": "1", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 47, + "typeId": 24, + "name": "体验不佳", + "value": "2", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 48, + "typeId": 24, + "name": "功能缺失", + "value": "3", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 49, + "typeId": 24, + "name": "其他", + "value": "-1", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 50, + "typeId": 25, + "name": "顺丰速运", + "value": "sf", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 51, + "typeId": 25, + "name": "申通快递", + "value": "sto", + "orderNum": 2, + "remark": null, + "parentId": null + }, + { + "id": 52, + "typeId": 25, + "name": "EMS", + "value": "EMS", + "orderNum": 3, + "remark": null, + "parentId": null + }, + { + "id": 53, + "typeId": 25, + "name": "韵达快递", + "value": "yundaex", + "orderNum": 4, + "remark": null, + "parentId": null + }, + { + "id": 54, + "typeId": 25, + "name": "百世快递", + "value": "best", + "orderNum": 5, + "remark": null, + "parentId": null + }, + { + "id": 55, + "typeId": 25, + "name": "圆通速递", + "value": "yto", + "orderNum": 6, + "remark": null, + "parentId": null + }, + { + "id": 56, + "typeId": 25, + "name": "中通快递", + "value": "zto", + "orderNum": 7, + "remark": null, + "parentId": null + }, + { + "id": 57, + "typeId": 26, + "name": "无理由", + "value": "", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 58, + "typeId": 26, + "name": "商品与页面描述不符", + "value": "", + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 59, + "typeId": 26, + "name": "少发/发错货/未收到货", + "value": null, + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 60, + "typeId": 26, + "name": "价格变化", + "value": null, + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 61, + "typeId": 26, + "name": "商品破损/包装问题", + "value": null, + "orderNum": 1, + "remark": null, + "parentId": null + }, + { + "id": 62, + "typeId": 26, + "name": "商品质量/故障", + "value": null, + "orderNum": 1, + "remark": null, + "parentId": null + } + ], + "dict_type": [ + { + "id": 21, + "name": "订单取消原因", + "key": "orderCancelReason" + }, + { + "id": 22, + "name": "升级类型", + "key": "upgradeType" + }, + { + "id": 23, + "name": "投诉类型", + "key": "complainType" + }, + { + "id": 24, + "name": "反馈类型", + "key": "feedbackType" + }, + { + "id": 25, + "name": "物流公司", + "key": "logisticsCompany" + }, + { + "id": 26, + "name": "订单退款原因", + "key": "orderRefundReason" + } + ] +} \ No newline at end of file diff --git a/src/modules/dict/entity/info.ts b/src/modules/dict/entity/info.ts new file mode 100644 index 0000000..68d4701 --- /dev/null +++ b/src/modules/dict/entity/info.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity } from 'typeorm'; + +/** + * 字典信息 + */ +@Entity('dict_info') +export class DictInfoEntity extends BaseEntity { + @Column({ comment: '类型ID' }) + typeId: number; + + @Column({ comment: '名称' }) + name: string; + + @Column({ comment: '值', nullable: true }) + value: string; + + @Column({ comment: '排序', default: 0 }) + orderNum: number; + + @Column({ comment: '备注', nullable: true }) + remark: string; + + @Column({ comment: '父ID', default: null }) + parentId: number; +} diff --git a/src/modules/dict/entity/type.ts b/src/modules/dict/entity/type.ts new file mode 100644 index 0000000..1b5d1b4 --- /dev/null +++ b/src/modules/dict/entity/type.ts @@ -0,0 +1,14 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity } from 'typeorm'; + +/** + * 字典类别 + */ +@Entity('dict_type') +export class DictTypeEntity extends BaseEntity { + @Column({ comment: '名称' }) + name: string; + + @Column({ comment: '标识' }) + key: string; +} diff --git a/src/modules/dict/service/info.ts b/src/modules/dict/service/info.ts new file mode 100644 index 0000000..5643e09 --- /dev/null +++ b/src/modules/dict/service/info.ts @@ -0,0 +1,137 @@ +import { DictTypeEntity } from './../entity/type'; +import { DictInfoEntity } from './../entity/info'; +import { Config, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository, In } from 'typeorm'; +import * as _ from 'lodash'; + +/** + * 字典信息 + */ +@Provide() +export class DictInfoService extends BaseService { + @InjectEntityModel(DictInfoEntity) + dictInfoEntity: Repository; + + @InjectEntityModel(DictTypeEntity) + dictTypeEntity: Repository; + + @Config('typeorm.dataSource.default.type') + ormType: string; + + /** + * 获得字典数据 + * @param types + */ + async data(types: string[]) { + const result = {}; + let typeData = await this.dictTypeEntity.find(); + if (!_.isEmpty(types)) { + typeData = await this.dictTypeEntity.findBy({ key: In(types) }); + } + if (_.isEmpty(typeData)) { + return {}; + } + const data = await this.dictInfoEntity + .createQueryBuilder('a') + .select([ + 'a.id', + 'a.name', + 'a.typeId', + 'a.parentId', + 'a.orderNum', + 'a.value', + ]) + .where('a.typeId in(:...typeIds)', { + typeIds: typeData.map(e => { + return e.id; + }), + }) + .orderBy('a.orderNum', 'ASC') + .addOrderBy('a.createTime', 'ASC') + .getMany(); + for (const item of typeData) { + result[item.key] = _.filter(data, { typeId: item.id }).map(e => { + const value = e.value ? Number(e.value) : e.value; + return { + ...e, + value: isNaN(value) ? e.value : value, + }; + }); + } + return result; + } + + /** + * 获得单个或多个字典值 + * @param value 字典值或字典值数组 + * @param key 字典类型 + * @returns + */ + async getValues(value: string | string[], key: string) { + // 获取字典类型 + const type = await this.dictTypeEntity.findOneBy({ key }); + if (!type) { + return null; // 或者适当的错误处理 + } + + // 根据typeId获取所有相关的字典信息 + const dictValues = await this.dictInfoEntity.find({ + where: { typeId: type.id }, + }); + + // 如果value是字符串,直接查找 + if (typeof value === 'string') { + return this.findValueInDictValues(value, dictValues); + } + + // 如果value是数组,遍历数组,对每个元素进行查找 + return value.map(val => this.findValueInDictValues(val, dictValues)); + } + + /** + * 在字典值数组中查找指定的值 + * @param value 要查找的值 + * @param dictValues 字典值数组 + * @returns + */ + findValueInDictValues(value: string, dictValues: any[]) { + let result = dictValues.find(dictValue => dictValue.value === value); + if (!result) { + result = dictValues.find(dictValue => dictValue.id === parseInt(value)); + } + return result ? result.name : null; // 或者适当的错误处理 + } + + /** + * 修改之后 + * @param data + * @param type + */ + async modifyAfter(data: any, type: 'delete' | 'update' | 'add') { + if (type === 'delete') { + for (const id of data) { + await this.delChildDict(id); + } + } + } + + /** + * 删除子字典 + * @param id + */ + private async delChildDict(id) { + const delDict = await this.dictInfoEntity.findBy({ parentId: id }); + if (_.isEmpty(delDict)) { + return; + } + const delDictIds = delDict.map(e => { + return e.id; + }); + await this.dictInfoEntity.delete(delDictIds); + for (const dictId of delDictIds) { + await this.delChildDict(dictId); + } + } +} diff --git a/src/modules/dict/service/type.ts b/src/modules/dict/service/type.ts new file mode 100644 index 0000000..7ff4b9d --- /dev/null +++ b/src/modules/dict/service/type.ts @@ -0,0 +1,25 @@ +import { DictInfoEntity } from './../entity/info'; +import { Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository, In } from 'typeorm'; + +/** + * 描述 + */ +@Provide() +export class DictTypeService extends BaseService { + @InjectEntityModel(DictInfoEntity) + dictInfoEntity: Repository; + + /** + * 删除 + * @param ids + */ + async delete(ids) { + super.delete(ids); + await this.dictInfoEntity.delete({ + typeId: In(ids), + }); + } +} diff --git a/src/modules/goods/config.ts b/src/modules/goods/config.ts new file mode 100644 index 0000000..5e280ed --- /dev/null +++ b/src/modules/goods/config.ts @@ -0,0 +1,19 @@ +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 模块配置 + */ +export default () => { + return { + // 模块名称 + name: '商品模块', + // 模块描述 + description: '商品、分类管理', + // 中间件,只对本模块有效 + middlewares: [], + // 中间件,全局有效 + globalMiddlewares: [], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 0, + } as ModuleConfig; +}; diff --git a/src/modules/goods/controller/admin/comment.ts b/src/modules/goods/controller/admin/comment.ts new file mode 100644 index 0000000..7b59f14 --- /dev/null +++ b/src/modules/goods/controller/admin/comment.ts @@ -0,0 +1,30 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { GoodsCommentEntity } from '../../entity/comment'; +import { UserInfoEntity } from '../../../user/entity/info'; +import { GoodsInfoEntity } from '../../entity/info'; + +/** + * 商品评论 + */ +@CoolController({ + api: ['page'], + entity: GoodsCommentEntity, + pageQueryOp: { + keyWordLikeFields: ['a.content', 'b.nickName'], + fieldEq: ['a.goodsId'], + select: ['a.*', 'b.nickName', 'b.avatarUrl', 'c.title', 'c.mainPic'], + join: [ + { + entity: UserInfoEntity, + alias: 'b', + condition: 'a.userId = b.id', + }, + { + entity: GoodsInfoEntity, + alias: 'c', + condition: 'a.goodsId = c.id', + }, + ], + }, +}) +export class AdminGoodsCommentController extends BaseController {} diff --git a/src/modules/goods/controller/admin/info.ts b/src/modules/goods/controller/admin/info.ts new file mode 100644 index 0000000..70829d8 --- /dev/null +++ b/src/modules/goods/controller/admin/info.ts @@ -0,0 +1,29 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { GoodsInfoEntity } from '../../entity/info'; +import { GoodsInfoService } from '../../service/info'; +import { GoodsTypeEntity } from '../../entity/type'; + +/** + * 商品信息 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: GoodsInfoEntity, + service: GoodsInfoService, + pageQueryOp: { + keyWordLikeFields: ['a.title', 'a.subTitle'], + select: ['a.*', 'b.name as typeName'], + fieldEq: ['a.status', 'a.typeId'], + addOrderBy: { + sortNum: 'desc', + }, + join: [ + { + entity: GoodsTypeEntity, + alias: 'b', + condition: 'a.typeId = b.id', + }, + ], + }, +}) +export class AdminGoodsInfoController extends BaseController {} diff --git a/src/modules/goods/controller/admin/searchKeyword.ts b/src/modules/goods/controller/admin/searchKeyword.ts new file mode 100644 index 0000000..4f9882f --- /dev/null +++ b/src/modules/goods/controller/admin/searchKeyword.ts @@ -0,0 +1,14 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { GoodsSearchKeywordEntity } from '../../entity/searchKeyword'; + +/** + * 商品信息 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: GoodsSearchKeywordEntity, + pageQueryOp: { + keyWordLikeFields: ['name'], + }, +}) +export class AdminGoodsSearchKeywordController extends BaseController {} diff --git a/src/modules/goods/controller/admin/spec.ts b/src/modules/goods/controller/admin/spec.ts new file mode 100644 index 0000000..ca9b1d5 --- /dev/null +++ b/src/modules/goods/controller/admin/spec.ts @@ -0,0 +1,14 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { GoodsSpecEntity } from '../../entity/spec'; + +/** + * 分类 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: GoodsSpecEntity, + listQueryOp: { + fieldEq: ['goodsId'], + }, +}) +export class AdminGoodsSpecController extends BaseController {} diff --git a/src/modules/goods/controller/admin/type.ts b/src/modules/goods/controller/admin/type.ts new file mode 100644 index 0000000..0801b46 --- /dev/null +++ b/src/modules/goods/controller/admin/type.ts @@ -0,0 +1,20 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { GoodsTypeEntity } from '../../entity/type'; +import { GoodsTypeService } from '../../service/type'; + +/** + * 分类 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: GoodsTypeEntity, + service: GoodsTypeService, + listQueryOp: { + keyWordLikeFields: ['name'], + }, + pageQueryOp: { + keyWordLikeFields: ['name'], + fieldEq: ['parentId'], + }, +}) +export class AdminGoodsTypeController extends BaseController {} diff --git a/src/modules/goods/controller/app/comment.ts b/src/modules/goods/controller/app/comment.ts new file mode 100644 index 0000000..521c871 --- /dev/null +++ b/src/modules/goods/controller/app/comment.ts @@ -0,0 +1,55 @@ +import { + CoolController, + BaseController, + TagTypes, + CoolUrlTag, +} from '@cool-midway/core'; +import { GoodsCommentEntity } from '../../entity/comment'; +import { UserInfoEntity } from '../../../user/entity/info'; +import { Body, Inject, Post } from '@midwayjs/core'; +import { GoodsCommentService } from '../../service/comment'; + +/** + * 商品评论 + */ +@CoolUrlTag({ + key: TagTypes.IGNORE_TOKEN, + value: ['page'], +}) +@CoolController({ + api: ['page'], + entity: GoodsCommentEntity, + insertParam: ctx => { + return { + userId: ctx.user.id, + }; + }, + pageQueryOp: { + fieldEq: ['a.goodsId', 'a.orderId'], + select: ['a.*', 'b.nickName', 'b.avatarUrl'], + join: [ + { + entity: UserInfoEntity, + alias: 'b', + condition: 'a.userId = b.id', + }, + ], + }, +}) +export class AppGoodsCommentController extends BaseController { + @Inject() + ctx; + + @Inject() + goodsCommentService: GoodsCommentService; + + @Post('/submit', { summary: '提交评论' }) + async submit( + @Body('data') data: GoodsCommentEntity, + @Body('orderId') orderId: number + ) { + return this.ok( + await this.goodsCommentService.submit(this.ctx.user.id, orderId, data) + ); + } +} diff --git a/src/modules/goods/controller/app/info.ts b/src/modules/goods/controller/app/info.ts new file mode 100644 index 0000000..430ff6b --- /dev/null +++ b/src/modules/goods/controller/app/info.ts @@ -0,0 +1,45 @@ +import { + CoolController, + BaseController, + CoolUrlTag, + TagTypes, +} from '@cool-midway/core'; +import { GoodsInfoEntity } from '../../entity/info'; +import { GoodsInfoService } from '../../service/info'; +import { GoodsTypeEntity } from '../../entity/type'; + +/** + * 商品信息 + */ +@CoolUrlTag({ + key: TagTypes.IGNORE_TOKEN, + value: ['page', 'info'], +}) +@CoolController({ + api: ['page', 'info'], + entity: GoodsInfoEntity, + service: GoodsInfoService, + pageQueryOp: { + keyWordLikeFields: ['title'], + fieldEq: ['a.typeId'], + select: ['a.*', 'b.name as typeName'], + where: ctx => { + const { minPrice, maxPrice } = ctx.request.body; + return [ + // 过滤掉已下架商品 + ['a.status = :status', { status: 1 }], + // 价格区间 + ['a.price >= :minPrice', { minPrice }, minPrice], + ['a.price <= :maxPrice', { maxPrice }, maxPrice], + ]; + }, + join: [ + { + entity: GoodsTypeEntity, + alias: 'b', + condition: 'a.typeId = b.id', + }, + ], + }, +}) +export class AppGoodsInfoController extends BaseController {} diff --git a/src/modules/goods/controller/app/searchKeyword.ts b/src/modules/goods/controller/app/searchKeyword.ts new file mode 100644 index 0000000..dbbdced --- /dev/null +++ b/src/modules/goods/controller/app/searchKeyword.ts @@ -0,0 +1,14 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { GoodsSearchKeywordEntity } from '../../entity/searchKeyword'; + +/** + * 商品信息 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: GoodsSearchKeywordEntity, + pageQueryOp: { + keyWordLikeFields: ['name'], + }, +}) +export class AppGoodsSearchKeywordController extends BaseController {} diff --git a/src/modules/goods/controller/app/spec.ts b/src/modules/goods/controller/app/spec.ts new file mode 100644 index 0000000..5b0829f --- /dev/null +++ b/src/modules/goods/controller/app/spec.ts @@ -0,0 +1,23 @@ +import { + CoolController, + BaseController, + TagTypes, + CoolUrlTag, +} from '@cool-midway/core'; +import { GoodsSpecEntity } from '../../entity/spec'; + +/** + * 分类 + */ +@CoolUrlTag({ + key: TagTypes.IGNORE_TOKEN, + value: ['list'], +}) +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: GoodsSpecEntity, + listQueryOp: { + fieldEq: ['goodsId'], + }, +}) +export class AppGoodsSpecController extends BaseController {} diff --git a/src/modules/goods/controller/app/type.ts b/src/modules/goods/controller/app/type.ts new file mode 100644 index 0000000..a6b35ae --- /dev/null +++ b/src/modules/goods/controller/app/type.ts @@ -0,0 +1,29 @@ +import { + CoolController, + BaseController, + TagTypes, + CoolUrlTag, +} from '@cool-midway/core'; +import { GoodsTypeEntity } from '../../entity/type'; + +/** + * 分类 + */ +@CoolUrlTag({ + key: TagTypes.IGNORE_TOKEN, + value: ['list'], +}) +@CoolController({ + api: ['list'], + entity: GoodsTypeEntity, + listQueryOp: { + keyWordLikeFields: ['a.name'], + addOrderBy: { + sortNum: 'desc', + }, + where: () => { + return [['a.status =:status', { status: 1 }]]; + }, + }, +}) +export class AppGoodsTypeController extends BaseController {} diff --git a/src/modules/goods/entity/comment.ts b/src/modules/goods/entity/comment.ts new file mode 100644 index 0000000..1f3c76a --- /dev/null +++ b/src/modules/goods/entity/comment.ts @@ -0,0 +1,29 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 商品信息 + */ +@Entity('goods_comment') +export class GoodsCommentEntity extends BaseEntity { + @Index() + @Column({ comment: '用户ID' }) + userId: number; + + @Index() + @Column({ comment: '商品ID' }) + goodsId: number; + + @Index() + @Column({ comment: '订单ID' }) + orderId: number; + + @Column({ comment: '内容' }) + content: string; + + @Column({ comment: '星数', default: 5 }) + starCount: number; + + @Column({ comment: '图片', type: 'json', nullable: true }) + pics: string[]; +} diff --git a/src/modules/goods/entity/info.ts b/src/modules/goods/entity/info.ts new file mode 100644 index 0000000..d25d8f0 --- /dev/null +++ b/src/modules/goods/entity/info.ts @@ -0,0 +1,50 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; +import { GoodsSpecEntity } from './spec'; + +/** + * 商品信息 + */ +@Entity('goods_info') +export class GoodsInfoEntity extends BaseEntity { + @Index() + @Column({ comment: '类型ID' }) + typeId: number; + + @Index() + @Column({ comment: '标题' }) + title: string; + + @Column({ comment: '副标题', nullable: true }) + subTitle: string; + + @Column({ comment: '主图' }) + mainPic: string; + + @Column({ comment: '图片', type: 'json', nullable: true }) + pics: string[]; + + @Column({ + comment: '价格', + type: 'decimal', + precision: 12, + scale: 2, + }) + price: number; + + @Column({ comment: '已售', default: 0 }) + sold: number; + + @Column({ comment: '详情', type: 'text', nullable: true }) + content: string; + + @Index() + @Column({ comment: '状态 0-下架 1-上架', default: 0 }) + status: number; + + @Column({ comment: '排序', default: 0, nullable: true }) + sortNum: number; + + // 规格,非表字段 + specs: GoodsSpecEntity[]; +} diff --git a/src/modules/goods/entity/searchKeyword.ts b/src/modules/goods/entity/searchKeyword.ts new file mode 100644 index 0000000..4ea4faa --- /dev/null +++ b/src/modules/goods/entity/searchKeyword.ts @@ -0,0 +1,14 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 搜索关键词 + */ +@Entity('goods_search_keyword') +export class GoodsSearchKeywordEntity extends BaseEntity { + @Column({ comment: '名称' }) + name: string; + + @Column({ comment: '排序', default: 0, nullable: true }) + sortNum: number; +} diff --git a/src/modules/goods/entity/spec.ts b/src/modules/goods/entity/spec.ts new file mode 100644 index 0000000..bc29125 --- /dev/null +++ b/src/modules/goods/entity/spec.ts @@ -0,0 +1,32 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 规格 + */ +@Entity('goods_spec') +export class GoodsSpecEntity extends BaseEntity { + @Index() + @Column({ comment: '商品ID' }) + goodsId: number; + + @Column({ comment: '名称' }) + name: string; + + @Column({ + comment: '价格', + type: 'decimal', + precision: 12, + scale: 2, + }) + price: number; + + @Column({ comment: '库存', default: 0 }) + stock: number; + + @Column({ comment: '排序', default: 0, nullable: true }) + sortNum: number; + + @Column({ comment: '图片', type: 'json', nullable: true }) + images: string[]; +} diff --git a/src/modules/goods/entity/type.ts b/src/modules/goods/entity/type.ts new file mode 100644 index 0000000..8c12dda --- /dev/null +++ b/src/modules/goods/entity/type.ts @@ -0,0 +1,24 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 规格 + */ +@Entity('goods_type') +export class GoodsTypeEntity extends BaseEntity { + @Column({ comment: '名称' }) + name: string; + + @Index() + @Column({ comment: '父ID', nullable: true }) + parentId: number; + + @Column({ comment: '排序', default: 0, nullable: true }) + sortNum: number; + + @Column({ comment: '图片', nullable: true }) + pic: string; + + @Column({ comment: '状态 0-禁用 1-启用', nullable: true, default: 1 }) + status: number; +} diff --git a/src/modules/goods/service/comment.ts b/src/modules/goods/service/comment.ts new file mode 100644 index 0000000..c84af89 --- /dev/null +++ b/src/modules/goods/service/comment.ts @@ -0,0 +1,62 @@ +import { Init, Inject, Provide } from '@midwayjs/decorator'; +import { BaseService, CoolCommException } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Equal, Repository } from 'typeorm'; +import { GoodsCommentEntity } from '../entity/comment'; +import { OrderInfoService } from '../../order/service/info'; +import { OrderGoodsEntity } from '../../order/entity/goods'; + +/** + * 商品评论 + */ +@Provide() +export class GoodsCommentService extends BaseService { + @InjectEntityModel(GoodsCommentEntity) + goodsCommentEntity: Repository; + + @InjectEntityModel(OrderGoodsEntity) + orderGoodsEntity: Repository; + + @Inject() + orderInfoService: OrderInfoService; + + @Init() + async init() { + await super.init(); + this.setEntity(this.goodsCommentEntity); + } + + /** + * 提交评论 + * @param userId + * @param orderId + * @param comment + */ + async submit(userId: number, orderId: number, comment: GoodsCommentEntity) { + const order = await this.orderInfoService.info(orderId); + if (order && order.userId != userId) { + throw new CoolCommException('无权限'); + } + if (![3, 4].includes(order.status)) { + throw new CoolCommException('不是可评价的订单状态'); + } + const goods = await this.orderGoodsEntity.findOneBy({ + goodsId: Equal(comment.goodsId), + orderId: Equal(orderId), + }); + if (!goods) { + throw new CoolCommException('商品不存在'); + } + if (goods.isComment) { + throw new CoolCommException('已评价过,不能重复评价'); + } + // 更新订单商品状态 + await this.orderGoodsEntity.update(goods.id, { isComment: 1 }); + // 订单状态改为交易完成 + if (order.status == 3) { + await this.orderInfoService.changeStatus(orderId, 4); + } + comment.userId = userId; + await this.add(comment); + } +} diff --git a/src/modules/goods/service/info.ts b/src/modules/goods/service/info.ts new file mode 100644 index 0000000..9a09df1 --- /dev/null +++ b/src/modules/goods/service/info.ts @@ -0,0 +1,56 @@ +import { Init, Inject, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { GoodsInfoEntity } from '../entity/info'; +import { GoodsSpecService } from './spec'; + +/** + * 商品信息 + */ +@Provide() +export class GoodsInfoService extends BaseService { + @InjectEntityModel(GoodsInfoEntity) + goodsInfoEntity: Repository; + + @Inject() + goodsSpecService: GoodsSpecService; + + @Init() + async init() { + await super.init(); + this.setEntity(this.goodsInfoEntity); + } + + /** + * 修改之后 + * @param data + * @param type + */ + async modifyAfter(data: any, type: 'add' | 'update' | 'delete') { + if ((type == 'add' || type == 'update') && data.specs) { + // 保存规格 + await this.goodsSpecService.save(data.id, data.specs); + } + if (type == 'delete') { + // 删除规格 + await this.goodsSpecService.removeByGoodsId([data.id]); + } + } + + /** + * 详情 + * @param id + * @param infoIgnoreProperty + * @returns + */ + async info(id: any, infoIgnoreProperty?: string[]) { + if (!id) return; + const info = await super.info(id, infoIgnoreProperty); + if (info) { + // 获取规格 + info.specs = await this.goodsSpecService.getByGoodsId(info.id); + } + return info; + } +} diff --git a/src/modules/goods/service/spec.ts b/src/modules/goods/service/spec.ts new file mode 100644 index 0000000..81f3792 --- /dev/null +++ b/src/modules/goods/service/spec.ts @@ -0,0 +1,71 @@ +import { Init, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Equal, In, Repository } from 'typeorm'; +import { GoodsSpecEntity } from '../entity/spec'; + +/** + * 规格 + */ +@Provide() +export class GoodsSpecService extends BaseService { + @InjectEntityModel(GoodsSpecEntity) + goodsSpecEntity: Repository; + + @Init() + async init() { + await super.init(); + this.setEntity(this.goodsSpecEntity); + } + + /** + * 保持规格 + * @param goodsId + * @param specs + */ + async save(goodsId: number, specs: GoodsSpecEntity[]) { + // 先删除原来的规格 + await this.goodsSpecEntity.delete({ goodsId }); + // 保存新的规格 + await this.goodsSpecEntity.save( + specs.map(item => { + item.goodsId = goodsId; + return item; + }) + ); + } + + /** + * 通过商品ID获取规格 + * @param goodsId + */ + async removeByGoodsId(goodsIds: number[]) { + await this.goodsSpecEntity.delete({ goodsId: In(goodsIds) }); + } + + /** + * 通过商品ID获取规格 + * @param goodsId + * @returns + */ + async getByGoodsId(goodsId: number) { + return await this.goodsSpecEntity.findBy({ goodsId: Equal(goodsId) }); + } + + /** + * 更新库存 + * @param specId 规格ID + * @param count 数量 + */ + async updateStock(specId: number, count: number) { + await this.goodsSpecEntity.increment({ id: specId }, 'stock', count); + + // 更新后检查库存,如果小于0则设置为0 + const spec = await this.goodsSpecEntity.findOneBy({ id: Equal(specId) }); + if (spec && spec.stock < 0) { + await this.goodsSpecEntity.update(spec.id, { + stock: 0, + }); + } + } +} diff --git a/src/modules/goods/service/type.ts b/src/modules/goods/service/type.ts new file mode 100644 index 0000000..a295c2f --- /dev/null +++ b/src/modules/goods/service/type.ts @@ -0,0 +1,30 @@ +import { Init, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { GoodsTypeEntity } from '../entity/type'; + +/** + * 商品类型 + */ +@Provide() +export class GoodsTypeService extends BaseService { + @InjectEntityModel(GoodsTypeEntity) + goodsTypeEntity: Repository; + + @Init() + async init() { + await super.init(); + this.setEntity(this.goodsTypeEntity); + } + + /** + * 删除 + * @param ids + */ + async delete(ids: any) { + await super.delete(ids); + // 删除子集 + await this.goodsTypeEntity.delete({ parentId: In(ids) }); + } +} diff --git a/src/modules/info/config.ts b/src/modules/info/config.ts new file mode 100644 index 0000000..00fdcaa --- /dev/null +++ b/src/modules/info/config.ts @@ -0,0 +1,19 @@ +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 模块配置 + */ +export default () => { + return { + // 模块名称 + name: '信息模块', + // 模块描述 + description: '轮播图等', + // 中间件,只对本模块有效 + middlewares: [], + // 中间件,全局有效 + globalMiddlewares: [], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 0, + } as ModuleConfig; +}; diff --git a/src/modules/info/controller/admin/banner.ts b/src/modules/info/controller/admin/banner.ts new file mode 100644 index 0000000..e1d08de --- /dev/null +++ b/src/modules/info/controller/admin/banner.ts @@ -0,0 +1,15 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { InfoBannerEntity } from '../../entity/banner'; + +/** + * 轮播图 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: InfoBannerEntity, + pageQueryOp: { + keyWordLikeFields: ['a.title'], + fieldEq: ['a.status'], + }, +}) +export class AdminInfoBannerController extends BaseController {} diff --git a/src/modules/info/controller/app/banner.ts b/src/modules/info/controller/app/banner.ts new file mode 100644 index 0000000..3f5926d --- /dev/null +++ b/src/modules/info/controller/app/banner.ts @@ -0,0 +1,27 @@ +import { + CoolController, + BaseController, + CoolUrlTag, + TagTypes, +} from '@cool-midway/core'; +import { InfoBannerEntity } from '../../entity/banner'; + +/** + * 轮播图 + */ +@CoolUrlTag({ + key: TagTypes.IGNORE_TOKEN, + value: ['list'], +}) +@CoolController({ + api: ['list'], + entity: InfoBannerEntity, + listQueryOp: { + keyWordLikeFields: ['a.title'], + fieldEq: ['a.status'], + where: () => { + return [['a.status =:status', { status: 1 }]]; + }, + }, +}) +export class AppInfoBannerController extends BaseController {} diff --git a/src/modules/info/entity/banner.ts b/src/modules/info/entity/banner.ts new file mode 100644 index 0000000..d22e1b2 --- /dev/null +++ b/src/modules/info/entity/banner.ts @@ -0,0 +1,23 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity } from 'typeorm'; + +/** + * 轮播图 + */ +@Entity('info_banner') +export class InfoBannerEntity extends BaseEntity { + @Column({ comment: '描述', nullable: true }) + description: string; + + @Column({ comment: '跳转路径', nullable: true }) + path: string; + + @Column({ comment: '图片' }) + pic: string; + + @Column({ comment: '排序', default: 0, nullable: true }) + sortNum: number; + + @Column({ comment: '状态 1:启用 2:禁用', default: 1 }) + status: number; +} diff --git a/src/modules/market/config.ts b/src/modules/market/config.ts new file mode 100644 index 0000000..a622185 --- /dev/null +++ b/src/modules/market/config.ts @@ -0,0 +1,19 @@ +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 模块配置 + */ +export default () => { + return { + // 模块名称 + name: '营销模块', + // 模块描述 + description: '优惠券', + // 中间件,只对本模块有效 + middlewares: [], + // 中间件,全局有效 + globalMiddlewares: [], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 0, + } as ModuleConfig; +}; diff --git a/src/modules/market/controller/admin/coupon/info.ts b/src/modules/market/controller/admin/coupon/info.ts new file mode 100644 index 0000000..68ffe09 --- /dev/null +++ b/src/modules/market/controller/admin/coupon/info.ts @@ -0,0 +1,17 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { MarketCouponInfoEntity } from '../../../entity/coupon/info'; +import { MarketCouponInfoService } from '../../../service/coupon/info'; + +/** + * 优惠券信息 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: MarketCouponInfoEntity, + service: MarketCouponInfoService, + pageQueryOp: { + keyWordLikeFields: ['a.title'], + fieldEq: ['a.status'], + }, +}) +export class AdminMarketCouponInfoController extends BaseController {} diff --git a/src/modules/market/controller/admin/coupon/user.ts b/src/modules/market/controller/admin/coupon/user.ts new file mode 100644 index 0000000..0ed0ce1 --- /dev/null +++ b/src/modules/market/controller/admin/coupon/user.ts @@ -0,0 +1,26 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { MarketCouponUserEntity } from '../../../entity/coupon/user'; +import { MarketCouponUserService } from '../../../service/coupon/user'; +import { UserInfoEntity } from '../../../../user/entity/info'; + +/** + * 优惠券用户 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: MarketCouponUserEntity, + service: MarketCouponUserService, + pageQueryOp: { + keyWordLikeFields: ['a.title', 'b.nickName'], + fieldEq: ['a.status', 'a.couponId', 'a.userId'], + select: ['a.*', 'b.nickName', 'b.avatarUrl'], + join: [ + { + entity: UserInfoEntity, + alias: 'b', + condition: 'a.userId = b.id', + }, + ], + }, +}) +export class AdminMarketCouponUserController extends BaseController {} diff --git a/src/modules/market/controller/app/coupon/info.ts b/src/modules/market/controller/app/coupon/info.ts new file mode 100644 index 0000000..4fdde04 --- /dev/null +++ b/src/modules/market/controller/app/coupon/info.ts @@ -0,0 +1,51 @@ +import { + CoolController, + BaseController, + QueryOp, + CoolUrlTag, + TagTypes, +} from '@cool-midway/core'; +import { MarketCouponInfoEntity } from '../../../entity/coupon/info'; +import { MarketCouponInfoService } from '../../../service/coupon/info'; +import * as moment from 'moment'; +import { MarketCouponUserEntity } from '../../../entity/coupon/user'; + +/** + * 优惠券信息 + */ +@CoolUrlTag({ + key: TagTypes.IGNORE_TOKEN, + value: ['page'], +}) +@CoolController({ + api: ['page'], + entity: MarketCouponInfoEntity, + service: MarketCouponInfoService, + // 获得ctx对象 + pageQueryOp: ctx => { + return new Promise(res => { + res({ + keyWordLikeFields: ['a.title'], + fieldEq: ['a.status'], + select: ['a.*', 'b.userId'], + where: () => { + return [ + // 只返回启用的优惠券 + ['a.status =:status', { status: 1 }], + // 只返回已开始和未结束的优惠券 + ['a.startTime <=:startTime', { startTime: moment().toDate() }], + ['a.endTime >=:endTime', { endTime: moment().toDate() }], + ]; + }, + join: [ + { + entity: MarketCouponUserEntity, + alias: 'b', + condition: 'a.id = b.couponId and b.userId =' + (ctx.user?.id || 0), + }, + ], + }); + }); + }, +}) +export class AppMarketCouponInfoController extends BaseController {} diff --git a/src/modules/market/controller/app/coupon/user.ts b/src/modules/market/controller/app/coupon/user.ts new file mode 100644 index 0000000..a0e6acd --- /dev/null +++ b/src/modules/market/controller/app/coupon/user.ts @@ -0,0 +1,48 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { MarketCouponUserEntity } from '../../../entity/coupon/user'; +import { MarketCouponUserService } from '../../../service/coupon/user'; +import { MarketCouponInfoService } from '../../../service/coupon/info'; +import { Inject, Post, Body } from '@midwayjs/core'; +import { MarketCouponInfoEntity } from '../../../entity/coupon/info'; + +/** + * 优惠券用户 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: MarketCouponUserEntity, + service: MarketCouponUserService, + pageQueryOp: { + keyWordLikeFields: ['a.title'], + fieldEq: ['a.status'], + select: ['b.*', 'a.status as useStatus'], + where: ctx => { + const userId = ctx.user.id; + return [ + // 只返回当前用户的优惠券 + ['a.userId =:userId', { userId }], + ]; + }, + join: [ + { + entity: MarketCouponInfoEntity, + alias: 'b', + condition: 'a.couponId = b.id', + }, + ], + }, +}) +export class AppMarketCouponUserController extends BaseController { + @Inject() + marketCouponInfoService: MarketCouponInfoService; + + @Inject() + ctx; + + @Post('/receive', { summary: '领取优惠券' }) + async receive(@Body('couponId') couponId: number) { + return this.ok( + await this.marketCouponInfoService.receive(couponId, this.ctx.user.id) + ); + } +} diff --git a/src/modules/market/entity/coupon/info.ts b/src/modules/market/entity/coupon/info.ts new file mode 100644 index 0000000..96adc00 --- /dev/null +++ b/src/modules/market/entity/coupon/info.ts @@ -0,0 +1,41 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity } from 'typeorm'; + +/** + * 优惠券信息 + */ +@Entity('market_coupon_info') +export class MarketCouponInfoEntity extends BaseEntity { + @Column({ comment: '标题' }) + title: string; + + @Column({ comment: '描述' }) + description: string; + + @Column({ comment: '类型 0-满减', default: 0 }) + type: number; + + @Column({ comment: '金额', type: 'decimal', precision: 12, scale: 2 }) + amount: number; + + @Column({ comment: '数量' }) + num: number; + + @Column({ comment: '已领取' }) + receivedNum: number; + + @Column({ comment: '开始时间' }) + startTime: Date; + + @Column({ comment: '结束时间' }) + endTime: Date; + + @Column({ comment: '状态 0-禁用 1-启用', default: 0 }) + status: number; + + @Column({ comment: '条件', type: 'json' }) + condition: { + // 满多少金额 + fullAmount: number; + }; +} diff --git a/src/modules/market/entity/coupon/user.ts b/src/modules/market/entity/coupon/user.ts new file mode 100644 index 0000000..e32f162 --- /dev/null +++ b/src/modules/market/entity/coupon/user.ts @@ -0,0 +1,22 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 优惠券用户 + */ +@Entity('market_coupon_user') +export class MarketCouponUserEntity extends BaseEntity { + @Index() + @Column({ comment: '用户ID' }) + userId: number; + + @Index() + @Column({ comment: '优惠券ID' }) + couponId: number; + + @Column({ comment: '状态 0-未使用 1-已使用', default: 0 }) + status: number; + + @Column({ comment: '使用时间', type: 'datetime', nullable: true }) + useTime: Date; +} diff --git a/src/modules/market/service/coupon/info.ts b/src/modules/market/service/coupon/info.ts new file mode 100644 index 0000000..3b40809 --- /dev/null +++ b/src/modules/market/service/coupon/info.ts @@ -0,0 +1,116 @@ +import { Init, Inject, Provide } from '@midwayjs/decorator'; +import { BaseService, CoolCommException } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { MarketCouponInfoEntity } from '../../entity/coupon/info'; +import { MarketCouponUserService } from './user'; +import { OrderInfoEntity } from '../../../order/entity/info'; +import * as moment from 'moment'; + +/** + * 优惠券信息 + */ +@Provide() +export class MarketCouponInfoService extends BaseService { + @InjectEntityModel(MarketCouponInfoEntity) + marketCouponInfoEntity: Repository; + + @Inject() + marketCouponUserService: MarketCouponUserService; + + @Init() + async init() { + await super.init(); + this.setEntity(this.marketCouponInfoEntity); + } + + /** + * 检查优惠券状态 + * @param couponId + */ + async check(couponId: number) { + const couponInfo: MarketCouponInfoEntity = await super.info(couponId); + if (!couponInfo) { + throw new CoolCommException('优惠券不存在'); + } + // 判断数量 + if (couponInfo.receivedNum >= couponInfo.num) { + throw new CoolCommException('优惠券已领完'); + } + // 判断时间 + if ( + moment().isBefore(couponInfo.startTime) || + moment().isAfter(couponInfo.endTime) + ) { + throw new CoolCommException('优惠券未开始或已结束'); + } + // 判断状态 + if (couponInfo.status !== 1) { + throw new CoolCommException('优惠券未启用'); + } + return couponInfo; + } + + /** + * 领取优惠券 + * @param couponId + * @param userId + */ + async receive(couponId: number, userId: number) { + // 检查优惠券 + await this.check(couponId); + // 检查用户是否已领取 + if (await this.marketCouponUserService.checkExist(couponId, userId)) { + throw new CoolCommException('已领取过该优惠券'); + } + // 保存 + await this.marketCouponUserService.save(couponId, userId); + // 增加领取数量 + await this.marketCouponInfoEntity.increment( + { id: couponId }, + 'receivedNum', + 1 + ); + } + + /** + * 检查优惠券是否满足条件,满足的话使用并设置和返回订单优惠金额 + * @param couponId + * @param userId + * @param order + */ + async checkAndUse(couponId: number, userId: number, order: OrderInfoEntity) { + // 判断优惠券 + const couponInfo = await this.check(couponId); + // 判断用户 + await this.marketCouponUserService.check(couponId, userId); + // 判断条件 + if (couponInfo.type == 0) { + // 满减 + if (order.price < couponInfo.condition.fullAmount) { + throw new CoolCommException('未满足优惠券使用条件'); + } + // 设置订单的优惠金额 + order.discountPrice = couponInfo.amount; + // 设置优惠来源 + order.discountSource = { + type: 0, + objectId: couponId, + info: couponInfo, + }; + // 设置商品的优惠金额 + if (order.price > 0) { + order.goodsList.forEach(goods => { + // 优惠金额 = 商品价格 / 订单总价 * 优惠金额 保留两位小数 + goods.discountPrice = Number( + ((goods.price / order.price) * couponInfo.amount).toFixed(2) + ); + }); + } + // 使用优惠券 + await this.marketCouponUserService.use(couponId, userId); + return couponInfo.amount; + } + return 0; + } +} diff --git a/src/modules/market/service/coupon/user.ts b/src/modules/market/service/coupon/user.ts new file mode 100644 index 0000000..4616e3e --- /dev/null +++ b/src/modules/market/service/coupon/user.ts @@ -0,0 +1,74 @@ +import { Init, Provide } from '@midwayjs/decorator'; +import { BaseService, CoolCommException } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Equal, Repository } from 'typeorm'; +import { MarketCouponUserEntity } from '../../entity/coupon/user'; + +/** + * 优惠券用户 + */ +@Provide() +export class MarketCouponUserService extends BaseService { + @InjectEntityModel(MarketCouponUserEntity) + marketCouponUserEntity: Repository; + + @Init() + async init() { + await super.init(); + this.setEntity(this.marketCouponUserEntity); + } + + /** + * 保存 + * @param idcouponId + * @param userId + */ + async save(couponId: number, userId: number) { + const couponUser = new MarketCouponUserEntity(); + couponUser.userId = userId; + couponUser.couponId = couponId; + await this.marketCouponUserEntity.save(couponUser); + } + + /** + * 检查优惠券是否可用 + * @param couponId + * @param userId + */ + async check(couponId: number, userId: number) { + const info = await this.marketCouponUserEntity.findOneBy({ + couponId: Equal(couponId), + userId: Equal(userId), + status: 0, + }); + if (!info) { + throw new CoolCommException('优惠券未领取或已使用'); + } + return info; + } + + /** + * 检查优惠券是否存在 + * @param couponId + * @param userId + */ + async checkExist(couponId: number, userId: number) { + const info = await this.marketCouponUserEntity.findOneBy({ + couponId: Equal(couponId), + userId: Equal(userId), + }); + return !!info; + } + + /** + * 使用优惠券 + * @param couponId + * @param userId + */ + async use(couponId: number, userId: number) { + await this.marketCouponUserEntity.update( + { couponId: Equal(couponId), userId: Equal(userId) }, + { status: 1, useTime: new Date() } + ); + } +} diff --git a/src/modules/order/config.ts b/src/modules/order/config.ts new file mode 100644 index 0000000..5e11cc4 --- /dev/null +++ b/src/modules/order/config.ts @@ -0,0 +1,19 @@ +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 模块配置 + */ +export default () => { + return { + // 模块名称 + name: '订单管理模块', + // 模块描述 + description: '订单、退款、支付等', + // 中间件,只对本模块有效 + middlewares: [], + // 中间件,全局有效 + globalMiddlewares: [], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 0, + } as ModuleConfig; +}; diff --git a/src/modules/order/controller/admin/info.ts b/src/modules/order/controller/admin/info.ts new file mode 100644 index 0000000..21b0c05 --- /dev/null +++ b/src/modules/order/controller/admin/info.ts @@ -0,0 +1,88 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { OrderInfoEntity } from '../../entity/info'; +import { OrderInfoService } from '../../service/info'; +import { UserInfoEntity } from '../../../user/entity/info'; +import { Body, Get, Inject, Post, Query } from '@midwayjs/core'; + +/** + * 订单信息 + */ +@CoolController({ + api: ['delete', 'update', 'info', 'list', 'page'], + entity: OrderInfoEntity, + service: OrderInfoService, + pageQueryOp: { + keyWordLikeFields: ['a.title', 'a.orderNum', 'b.nickName'], + select: ['a.*', 'b.nickName', 'b.avatarUrl'], + fieldEq: ['a.status', 'a.payType'], + join: [ + { + entity: UserInfoEntity, + alias: 'b', + condition: 'a.userId = b.id', + }, + ], + where: ctx => { + const { startTime, endTime, refundApplyStartTime, refundApplyEndTime } = + ctx.request.body; + return [ + // 过滤创建时间 + ['a.createTime >= :startTime', { startTime }, startTime], + ['a.createTime <= :endTime', { endTime }, endTime], + // 过滤退款时间 + [ + 'a.refundApplyTime >= :refundApplyStartTime', + { refundApplyStartTime }, + refundApplyStartTime, + ], + [ + 'a.refundApplyTime <= :refundApplyEndTime', + { refundApplyEndTime }, + refundApplyEndTime, + ], + ]; + }, + }, +}) +export class AdminOrderInfoController extends BaseController { + @Inject() + orderInfoService: OrderInfoService; + + @Post('/refundHandle', { summary: '退款处理' }) + async refundHandle( + @Body('orderId') orderId: number, + // 0-拒绝 1-同意 + @Body('action') action: number, + // 拒绝原因 + @Body('refuseReason') refuseReason: string, + // 退款金额 + @Body('amount') amount: number + ) { + await this.orderInfoService.refundHandle( + orderId, + action, + refuseReason, + amount + ); + return this.ok(); + } + + @Get('/logistics', { summary: '物流信息' }) + async logistics(@Query('orderId') orderId: number) { + return this.ok(await this.orderInfoService.logistics(orderId)); + } + + @Post('/deliver', { summary: '发货' }) + async deliver( + @Body('orderId') orderId: number, + @Body('logistics') + logistics: { + // 物流公司 + company: string; + // 物流单号 + num: string; + } + ) { + return this.ok(await this.orderInfoService.deliver(orderId, logistics)); + } +} diff --git a/src/modules/order/controller/app/info.ts b/src/modules/order/controller/app/info.ts new file mode 100644 index 0000000..0018a8b --- /dev/null +++ b/src/modules/order/controller/app/info.ts @@ -0,0 +1,86 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { OrderInfoEntity } from '../../entity/info'; +import { OrderInfoService } from '../../service/info'; +import { Body, Get, Inject, Post, Query } from '@midwayjs/core'; +import { OrderGoodsEntity } from '../../entity/goods'; + +/** + * 订单信息 + */ +@CoolController({ + api: ['info', 'page', 'update'], + entity: OrderInfoEntity, + service: OrderInfoService, + pageQueryOp: { + fieldEq: ['status'], + where: ctx => { + return [ + // 过滤用户ID + ['a.userId = :userId', { userId: ctx.user.id }], + ]; + }, + }, +}) +export class AppOrderInfoController extends BaseController { + @Inject() + orderInfoService: OrderInfoService; + + @Inject() + ctx; + + @Post('/create', { summary: '创建订单' }) + async create( + @Body('data') + data: { + goodsList: OrderGoodsEntity[]; + addressId: number; + remark: string; + title: string; + couponId: number; + } + ) { + return this.ok( + await this.orderInfoService.create({ + ...data, + userId: this.ctx.user.id, + }) + ); + } + + @Post('/cancel', { summary: '取消订单' }) + async cancel( + @Body('orderId') orderId: number, + @Body('remark') remark: string + ) { + return this.ok(await this.orderInfoService.close(orderId, remark)); + } + + @Post('/refund', { summary: '退款' }) + async refund( + @Body('orderId') orderId: number, + @Body('reason') reason: string + ) { + return this.ok( + await this.orderInfoService.refund(this.ctx.user.id, orderId, reason) + ); + } + + @Get('/confirm', { summary: '确认收货' }) + async confirm(@Query('orderId') orderId: number) { + return this.ok( + await this.orderInfoService.confirm(orderId, this.ctx.user.id) + ); + } + + @Get('/logistics', { summary: '物流信息' }) + async logistics(@Query('orderId') orderId: number) { + return this.ok( + await this.orderInfoService.logistics(orderId, this.ctx.user.id) + ); + } + + @Get('/userCount', { summary: '用户订单统计' }) + async userCount() { + return this.ok(await this.orderInfoService.userCount(this.ctx.user.id)); + } +} diff --git a/src/modules/order/controller/app/pay.ts b/src/modules/order/controller/app/pay.ts new file mode 100644 index 0000000..7154b98 --- /dev/null +++ b/src/modules/order/controller/app/pay.ts @@ -0,0 +1,64 @@ +import { + CoolController, + BaseController, + CoolUrlTag, + CoolTag, + TagTypes, +} from '@cool-midway/core'; +import { Body, Inject, Post } from '@midwayjs/core'; +import { PluginService } from '../../../plugin/service/info'; +import { OrderPayService } from '../../service/pay'; + +/** + * 支付 + */ +@CoolUrlTag() +@CoolController() +export class OrderPayController extends BaseController { + @Inject() + pluginService: PluginService; + + @Inject() + orderPayService: OrderPayService; + + @Inject() + ctx; + + @CoolTag(TagTypes.IGNORE_TOKEN) + @Post('/wxNotify', { summary: '微信支付回调' }) + async wxNotify(@Body() body) { + // 获得插件实例 + const plugin = await this.pluginService.getInstance('pay-wx'); + // 获得微信支付 SDK 实例 + const instance = await plugin.getInstance(); + const { ciphertext, associated_data, nonce } = body.resource; + const data: any = instance.decipher_gcm(ciphertext, associated_data, nonce); + const check = await plugin.signVerify(this.ctx); + // 验签通过,处理业务逻辑 + if (check && data.trade_state == 'SUCCESS') { + return await this.orderPayService.paySuccess(data.out_trade_no, 1); + } + return 'fail'; + } + + @Post('/wxMiniPay', { summary: '微信小程序支付' }) + async wxMiniPay(@Body('orderId') orderId) { + return this.ok( + await this.orderPayService.wxMiniPay(orderId, this.ctx.user.id) + ); + } + + @Post('/wxMpPay', { summary: '微信公众号支付' }) + async wxMpPay(@Body('orderId') orderId) { + return this.ok( + await this.orderPayService.wxMpPay(orderId, this.ctx.user.id) + ); + } + + @Post('/wxAppPay', { summary: '微信APP支付' }) + async wxAppPay(@Body('orderId') orderId) { + return this.ok( + await this.orderPayService.wxAppPay(orderId, this.ctx.user.id) + ); + } +} diff --git a/src/modules/order/entity/goods.ts b/src/modules/order/entity/goods.ts new file mode 100644 index 0000000..0aa1212 --- /dev/null +++ b/src/modules/order/entity/goods.ts @@ -0,0 +1,51 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; +import { GoodsInfoEntity } from '../../goods/entity/info'; +import { GoodsSpecEntity } from '../../goods/entity/spec'; + +/** + * 订单商品 + */ +@Entity('order_goods') +export class OrderGoodsEntity extends BaseEntity { + @Index() + @Column({ comment: '订单ID' }) + orderId: number; + + @Index() + @Column({ comment: '商品ID' }) + goodsId: number; + + @Column({ + comment: '价格', + type: 'decimal', + precision: 12, + scale: 2, + }) + price: number; + + @Column({ + comment: '优惠金额', + type: 'decimal', + scale: 2, + precision: 12, + default: 0, + }) + discountPrice: number; + + @Column({ comment: '数量' }) + count: number; + + @Column({ comment: '其他信息', nullable: true }) + remark: string; + + // 随着时间的推移,商品信息会变,这边必须记录购买时的商品信息 + @Column({ comment: '商品信息', type: 'json' }) + goodsInfo: GoodsInfoEntity; + + @Column({ comment: '规格', type: 'json', nullable: true }) + spec: GoodsSpecEntity; + + @Column({ comment: '是否评价 0-否 1-是', default: 0 }) + isComment: number; +} diff --git a/src/modules/order/entity/info.ts b/src/modules/order/entity/info.ts new file mode 100644 index 0000000..ee48bb0 --- /dev/null +++ b/src/modules/order/entity/info.ts @@ -0,0 +1,126 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; +import { UserAddressEntity } from '../../user/entity/address'; +import { OrderGoodsEntity } from './goods'; + +/** + * 订单信息 + */ +@Entity('order_info') +export class OrderInfoEntity extends BaseEntity { + @Index() + @Column({ comment: '用户ID' }) + userId: number; + + @Column({ comment: '标题', nullable: true }) + title: string; + + @Column({ comment: '支付方式 0-待支付 1-微信 2-支付宝', default: 0 }) + payType: number; + + @Column({ comment: '支付时间', type: 'datetime', nullable: true }) + payTime: Date; + + @Index() + @Column({ comment: '订单号', nullable: true }) + orderNum: string; + + @Column({ + comment: + '状态 0-待付款 1-待发货 2-待收货 3-待评价 4-交易完成 5-退款中 6-已退款 7-已关闭', + type: 'tinyint', + default: 0, + }) + status: number; + + @Column({ + comment: '价格', + type: 'decimal', + precision: 12, + scale: 2, + }) + price: number; + + @Column({ + comment: '优惠金额', + type: 'decimal', + scale: 2, + precision: 12, + default: 0, + }) + discountPrice: number; + + @Column({ comment: '优惠来源', type: 'json', nullable: true }) + discountSource: { + // 0-优惠券 + type: number; + // 对象ID + objectId: number; + // 信息 + info: any; + }; + + @Column({ comment: '地址', type: 'json', nullable: true }) + address: UserAddressEntity; + + @Column({ comment: '物流信息', type: 'json', nullable: true }) + logistics: { + // 物流公司 + company: string; + // 物流单号 + num: string; + }; + + @Column({ comment: '退款', type: 'json', nullable: true }) + refund: { + // 退款单号 + orderNum: string; + // 金额 + amount: number; + // 实际退款金额 + realAmount: number; + // 状态 0-申请中 1-已退款 2-拒绝 + status: number; + // 申请时间 + applyTime: Date; + // 退款时间 + time: Date; + // 退款原因 + reason: string; + // 拒绝原因 + refuseReason: string; + }; + + @Index() + @Column({ + asExpression: "JSON_EXTRACT(refund, '$.status')", + generatedType: 'VIRTUAL', + comment: '退款状态', + nullable: true, + }) + refundStatus: number; + + @Index() + @Column({ + asExpression: "JSON_EXTRACT(refund, '$.applyTime')", + generatedType: 'VIRTUAL', + comment: '退款申请时间', + nullable: true, + }) + refundApplyTime: Date; + + @Column({ comment: '备注', nullable: true }) + remark: string; + + @Column({ comment: '关闭备注', nullable: true }) + closeRemark: string; + + @Column({ comment: '已开票: 0-未开票 1-已开票', default: 0 }) + invoice: number; + + @Column({ comment: '微信类型 0-小程序 1-公众号 2-App', default: 0 }) + wxType: number; + + // 订单商品 + goodsList: OrderGoodsEntity[]; +} diff --git a/src/modules/order/queue/order.ts b/src/modules/order/queue/order.ts new file mode 100644 index 0000000..8e5058c --- /dev/null +++ b/src/modules/order/queue/order.ts @@ -0,0 +1,33 @@ +import { BaseCoolQueue, CoolQueue } from '@cool-midway/task'; +import { Inject } from '@midwayjs/core'; +import { OrderInfoService } from '../service/info'; + +// 行为 +export enum Action { + // 超时未支付 + TIMEOUT = 0, + // 自动确认收货 + CONFIRM = 1, +} + +/** + * 队列 + */ +@CoolQueue() +export abstract class OrderQueue extends BaseCoolQueue { + @Inject() + orderInfoService: OrderInfoService; + + async data(job: any, done: any) { + const action = job.data.action; + if (action == Action.TIMEOUT) { + // 关闭订单 + await this.orderInfoService.close(job.data.orderId, '超时未支付'); + } + if (action == Action.CONFIRM) { + // 自动确认收货 + await this.orderInfoService.autoConfirm(job.data.orderId); + } + done(); + } +} diff --git a/src/modules/order/service/goods.ts b/src/modules/order/service/goods.ts new file mode 100644 index 0000000..fdf8302 --- /dev/null +++ b/src/modules/order/service/goods.ts @@ -0,0 +1,80 @@ +import { Init, Inject, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Equal, In, Repository } from 'typeorm'; +import { OrderGoodsEntity } from '../entity/goods'; +import BigNumber from 'bignumber.js'; +import { GoodsSpecService } from '../../goods/service/spec'; + +/** + * 订单商品 + */ +@Provide() +export class OrderGoodsService extends BaseService { + @InjectEntityModel(OrderGoodsEntity) + orderGoodsEntity: Repository; + + @Inject() + goodsSpecService: GoodsSpecService; + + @Init() + async init() { + await super.init(); + this.setEntity(this.orderGoodsEntity); + } + + /** + * 保存 + * @param orderId + * @param goodsList + */ + async save(orderId: number, goodsList: OrderGoodsEntity[]) { + await this.orderGoodsEntity.save( + goodsList.map(item => { + item.orderId = orderId; + return item; + }) + ); + } + + /** + * 获得总价 + * @param goodsList + */ + async getTotalPrice(goodsList: OrderGoodsEntity[]) { + let totalPrice = new BigNumber(0); + for (const goods of goodsList) { + totalPrice = totalPrice.plus(goods.price * goods.count); + } + return totalPrice.toNumber(); + } + + /** + * 通过订单ID获取商品 + * @param orderId + */ + async getByOrderId(orderId: number) { + return await this.orderGoodsEntity.findBy({ orderId: Equal(orderId) }); + } + + /** + * 通过订单ID获取商品 + * @param orderId + */ + async getByOrderIds(orderIds: number[]) { + return await this.orderGoodsEntity.findBy({ orderId: In(orderIds) }); + } + + // 更新库存 + async updateStock( + goodsList: OrderGoodsEntity[], + type: 'add' | 'sub' = 'sub' + ) { + for (const goods of goodsList) { + await this.goodsSpecService.updateStock( + goods.spec.id, + type == 'add' ? goods.count : -goods.count + ); + } + } +} diff --git a/src/modules/order/service/info.ts b/src/modules/order/service/info.ts new file mode 100644 index 0000000..7fddae6 --- /dev/null +++ b/src/modules/order/service/info.ts @@ -0,0 +1,463 @@ +import { Init, Inject, Provide } from '@midwayjs/decorator'; +import { BaseService, CoolCommException } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Equal, In, Repository } from 'typeorm'; +import { OrderInfoEntity } from '../entity/info'; +import { OrderGoodsService } from './goods'; +import { OrderGoodsEntity } from '../entity/goods'; +import * as moment from 'moment'; +import { UserAddressService } from '../../user/service/address'; +import { Action, OrderQueue } from '../queue/order'; +import { OrderPayService } from './pay'; +import { PluginService } from '../../plugin/service/info'; +import { GoodsSpecEntity } from '../../goods/entity/spec'; +import { MarketCouponInfoService } from '../../market/service/coupon/info'; +import { MarketCouponUserEntity } from '../../market/entity/coupon/user'; +import { BaseSysParamService } from '../../base/service/sys/param'; +import BigNumber from 'bignumber.js'; + +/** + * 订单信息 + */ +@Provide() +export class OrderInfoService extends BaseService { + @InjectEntityModel(OrderInfoEntity) + orderInfoEntity: Repository; + + @InjectEntityModel(GoodsSpecEntity) + goodsSpecEntity: Repository; + + @InjectEntityModel(MarketCouponUserEntity) + marketCouponUserEntity: Repository; + + @Inject() + orderGoodsService: OrderGoodsService; + + @Inject() + userAddressService: UserAddressService; + + @Inject() + orderPayService: OrderPayService; + + @Inject() + orderQueue: OrderQueue; + + @Inject() + pluginService: PluginService; + + @Inject() + marketCouponInfoService: MarketCouponInfoService; + + @Inject() + baseSysParamService: BaseSysParamService; + + @Init() + async init() { + await super.init(); + this.setEntity(this.orderInfoEntity); + } + + /** + * 修改前 + * @param data + * @param type + */ + async modifyBefore(data: any, type: 'add' | 'update' | 'delete') { + if (type == 'add' || type == 'update') { + delete data.refundStatus; + delete data.refundApplyTime; + } + } + + /** + * 分页查询 + * @param query + * @param option + * @param connectionName + */ + async page(query: any, option: any, connectionName?: any) { + const result = await super.page(query, option, connectionName); + // 筛选条件的非分页sql + const countSql = await super.getOptionFind(query, option); + + const countResult = await this.nativeQuery( + `SELECT COUNT(a.id) as totalCount, SUM(a.price) as totalPrice FROM ${ + countSql.split('FROM')[1].split('ORDER BY')[0] + }` + ); + + result['subData'] = { + totalCount: countResult[0].totalCount, + totalPrice: countResult[0].totalPrice, + }; + + const goodsList = await this.orderGoodsService.getByOrderIds( + result.list.map(e => e.id) + ); + for (const item of result.list) { + item.goodsList = goodsList.filter(e => e.orderId == item.id); + } + return result; + } + + /** + * 根据订单号获取订单 + * @param orderNum + * @returns + */ + async getByOrderNum(orderNum: string) { + return this.orderInfoEntity.findOneBy({ + orderNum: Equal(orderNum), + }); + } + + /** + * 修改订单状态 + * @param id + * @param status + */ + async changeStatus(id: number, status: number) { + await this.orderInfoEntity.update({ id }, { status }); + } + + /** + * 关闭订单 + * @param orderId + * @param remark + */ + async close(orderId: number, remark: string) { + const order = await this.info(orderId); + if (!order || !remark) + throw new CoolCommException('订单不存在或备注不能为空'); + if (order.status != 0) { + throw new CoolCommException('订单状态不允许关闭'); + } + + // 退回优惠券 + if (order.discountSource && order.discountPrice > 0) { + if (order.discountSource.type == 0) { + this.marketCouponUserEntity.update( + { + id: order.discountSource.objectId, + }, + { + status: 0, + } + ); + } + } + + await this.orderInfoEntity.update( + { id: orderId }, + { status: 7, closeRemark: remark } + ); + + // 释放库存 + await this.orderGoodsService.updateStock(order.goodsList, 'add'); + } + + /** + * 订单详情 + * @param id + * @param infoIgnoreProperty + */ + async info(id: any, infoIgnoreProperty?: string[]) { + const info = await super.info(id, infoIgnoreProperty); + if (!info) { + throw new CoolCommException('订单不存在'); + } + if (info) { + // 获取商品 + info.goodsList = await this.orderGoodsService.getByOrderId(info.id); + } + return info; + } + + /** + * 创建订单 + * @param data + */ + async create(data: { + userId: number; + goodsList: OrderGoodsEntity[]; + addressId: number; + remark: string; + title: string; + couponId?: number; + }) { + const address = await this.userAddressService.info(data.addressId); + const order = { + userId: data.userId, + address, + remark: data.remark, + title: data.title, + goodsList: data.goodsList, + } as OrderInfoEntity; + // 检查库存 + await this.checkStock(data.goodsList); + order.price = await this.orderGoodsService.getTotalPrice(data.goodsList); + order.goodsList = data.goodsList; + + // 使用优惠券 + if (data.couponId) { + await this.marketCouponInfoService.checkAndUse( + data.couponId, + data.userId, + order + ); + } + await this.orderInfoEntity.insert(order); + + // 生成订单号 + const orderNum = await this.generateOrderNum(order.id); + + // 更新订单 + await this.orderInfoEntity.update(order.id, { + orderNum, + }); + + // 保存订单商品 + await this.orderGoodsService.save(order.id, data.goodsList); + + // 更新商品库存 + await this.orderGoodsService.updateStock(data.goodsList); + + const orderTimeout = await this.baseSysParamService.dataByKey( + 'orderTimeout' + ); + // 发送订单创建消息 + this.orderQueue.add( + { orderId: order.id, action: Action.TIMEOUT }, + { + // 超时关闭订单 + delay: orderTimeout * 60 * 1000, + } + ); + return order; + } + + /** + * 生成订单号 + * @param orderId + */ + async generateOrderNum(orderId: number, label = 'U') { + const orderNum = + moment().format('YYYYMMDDHHmmss') + + Math.floor(Math.random() * 10000).toString() + + orderId.toString(); + return label + orderNum; + } + + /** + * 退款 + * @param userId + * @param orderId + * @param goodsId + * @param reason + */ + async refund(userId: number, orderId: number, reason: string) { + const order = await this.info(orderId); + if (order && order.userId != userId) { + throw new CoolCommException('非法操作'); + } + if (![1, 2].includes(order.status)) { + throw new CoolCommException('订单状态不允许退款'); + } + + await this.orderInfoEntity.update( + { id: Equal(orderId) }, + { + status: 5, + refund: { + amount: new BigNumber(order.price) + .minus(order.discountPrice) + .toNumber(), + status: 0, + applyTime: new Date(), + reason, + orderNum: 'R' + order.orderNum.slice(1), + }, + } + ); + } + + /** + * 退款处理 + * @param orderId + * @param action 0-拒绝 1-同意 + * @param refuseReason 0-拒绝 1-同意 + * @param amount + */ + async refundHandle( + orderId: number, + action: number, + refuseReason: string, + amount: number + ) { + const order = await this.info(orderId); + if (order.status != 5 || !order.refund) { + throw new CoolCommException('订单状态不允许退款处理'); + } + // 拒绝退款 + if (action == 0) { + await this.orderInfoEntity.update( + { id: Equal(orderId) }, + { + status: 4, + refund: { + ...order.refund, + status: 2, + refuseReason: refuseReason, + }, + } + ); + } + // 同意退款 + if (action == 1) { + if (amount > order.price) { + throw new CoolCommException('退款金额不能大于订单金额'); + } + // 执行退款操作 + const result = await this.orderPayService.wxRefund(order, amount); + + if (result) { + await this.orderInfoEntity.update( + { id: Equal(orderId) }, + { + status: 6, + refund: { + ...order.refund, + status: 1, + realAmount: amount, + time: new Date(), + }, + } + ); + } + } + } + + /** + * 确认收货 + * @param userId + * @param orderId + */ + async confirm(orderId: number, userId: number) { + const order = await this.info(orderId); + if (order && order.userId != userId) { + throw new CoolCommException('非法操作'); + } + if (![2].includes(order.status)) { + throw new CoolCommException('订单状态不允许退款'); + } + await this.orderInfoEntity.update( + { id: Equal(orderId) }, + { + status: 3, + } + ); + } + + /** + * 自动确认收货 + * @param orderId + * @returns + */ + async autoConfirm(orderId: number) { + const info = await this.orderInfoEntity.findOneBy({ id: Equal(orderId) }); + if (info.status != 2) { + return; + } + await this.orderInfoEntity.update({ id: Equal(orderId) }, { status: 3 }); + } + + /** + * 物流信息 + * @param orderId + */ + async logistics(orderId: number, userId?: number) { + const order: OrderInfoEntity = await this.info(orderId); + if (userId && order.userId != userId) { + throw new CoolCommException('非法操作'); + } + const no = order.logistics?.num; + + if (!no) { + return null; + } + + const instance = await this.pluginService.getInstance('wuliu'); + return await instance.query(no); + } + + /** + * 发货 + * @param orderId + * @param logistics + */ + async deliver( + orderId: number, + logistics: { + // 物流公司 + company: string; + // 物流单号 + num: string; + } + ) { + const order: OrderInfoEntity = await this.info(orderId); + if (order.status != 1) { + throw new CoolCommException('订单状态不允许发货'); + } + await this.orderInfoEntity.update(orderId, { + status: 2, + logistics, + }); + } + + /** + * 检查库存 + * @param goodsList + */ + async checkStock(goodsList: OrderGoodsEntity[]) { + const specs = goodsList.map(item => item.spec); + const specList = await this.goodsSpecEntity.findBy({ + id: In(specs.map(e => e.id)), + }); + for (const spec of specList) { + const goods = goodsList.find(e => e.spec.id == spec.id); + // 设置商品价格 + goods.price = spec.price; + if (goods.count > spec.stock) { + throw new CoolCommException(`商品[${goods.goodsInfo.title}],库存不足`); + } + } + } + + /** + * 用户订单数量 + * @param userId + */ + async userCount(userId: number) { + const statusLabels = [ + '待付款', + '待发货', + '待收货', + '待评价', + '交易完成', + '退款中', + '已退款', + '已关闭', + ]; + // 生成查询字符串 + const selectQueries = statusLabels.map( + (label, index) => + `SUM(CASE WHEN status = ${index} THEN 1 ELSE 0 END) AS '${label}'` + ); + const list = await this.orderInfoEntity + .createQueryBuilder('a') + .select(selectQueries) + .where('a.userId = :userId', { userId }) + .getRawMany(); + return list[0]; + } +} diff --git a/src/modules/order/service/pay.ts b/src/modules/order/service/pay.ts new file mode 100644 index 0000000..fac9136 --- /dev/null +++ b/src/modules/order/service/pay.ts @@ -0,0 +1,238 @@ +import { Init, Inject, Provide } from '@midwayjs/decorator'; +import { BaseService, CoolCommException } from '@cool-midway/core'; +import { PluginService } from '../../plugin/service/info'; +import { OrderInfoService } from './info'; +import { UserWxService } from '../../user/service/wx'; +import BigNumber from 'bignumber.js'; +import { OrderInfoEntity } from '../entity/info'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { Action, OrderQueue } from '../queue/order'; +import { BaseSysParamService } from '../../base/service/sys/param'; + +/** + * 支付 + */ +@Provide() +export class OrderPayService extends BaseService { + @InjectEntityModel(OrderInfoEntity) + orderInfoEntity: Repository; + + @Inject() + pluginService: PluginService; + + @Inject() + orderInfoService: OrderInfoService; + + @Inject() + userWxService: UserWxService; + + @Inject() + orderQueue: OrderQueue; + + @Inject() + baseSysParamService: BaseSysParamService; + + @Init() + async init() { + await super.init(); + } + + /** + * 支付成功 + * @param orderNum 订单号 + * @param payType 支付方式 0-待支付 1-微信 2-支付宝 + */ + async paySuccess(orderNum: string, payType: number) { + const order = await this.orderInfoService.getByOrderNum(orderNum); + if (order && order.status == 0) { + await this.orderInfoEntity.update(order.id, { + payType, + status: 1, + payTime: order.payTime, + }); + // 发送自动确认收货队列 + const orderConfirm = await this.baseSysParamService.dataByKey( + 'orderConfirm' + ); + this.orderQueue.add( + { orderId: order.id, action: Action.CONFIRM }, + { + // 自动确认收货时间 + delay: orderConfirm * 60 * 60 * 1000, + } + ); + } + return 'success'; + } + + /** + * 微信小程序支付 + * @param userId + * @param orderId + */ + async wxMiniPay(orderId: number, userId: number) { + await this.orderInfoEntity.update(orderId, { wxType: 0 }); + return await this.wxJSAPI(orderId, userId, 0); + } + + /** + * 微信公众号支付 + * @param userId + * @param orderId + */ + async wxMpPay(orderId: number, userId: number) { + await this.orderInfoEntity.update(orderId, { wxType: 1 }); + return await this.wxJSAPI(orderId, userId, 1); + } + + /** + * 获得appid + * @param type 0-小程序 1-公众号 2-App + */ + async getAppidByType(type: number) { + let account; + const plugin = await this.pluginService.getInstance('wx'); + // 小程序 + if (type == 0) { + account = (await plugin.MiniApp()).getAccount(); + } + // 公众号 + if (type == 1) { + account = (await plugin.OfficialAccount()).getAccount(); + } + // App + if (type == 2) { + account = (await plugin.OpenPlatform()).getAccount(); + } + // 获得appid + const appid = account.getAppId(); + return appid; + } + + /** + * 获得微信支付 SDK 实例 + * @param type 0-小程序 1-公众号 2-App + * @returns + */ + async wxPayInstance(appid: string) { + // 获得插件实例 + const plugin = await this.pluginService.getInstance('pay-wx'); + // 获得插件配置 + const config = await plugin.getConfig(); + // 获得微信支付 SDK 实例 + const instance = await plugin.getInstance({ + ...config, + appid, + }); + return { config, instance }; + } + + /** + * 微信APP支付 + * @param orderId + * @param userId + * @returns + */ + async wxAppPay(orderId: number, userId: number) { + const appid = await this.getAppidByType(2); + + const { config, instance } = await this.wxPayInstance(appid); + + const order = await this.getOrder(orderId, userId); + const params = { + description: '商品采购', + out_trade_no: order?.orderNum, + notify_url: config.notify_url, + amount: { + total: new BigNumber(order.price) + .minus(order.discountPrice || 0) + .multipliedBy(100) + .toNumber(), + }, + }; + + const result = await instance.transactions_app(params); + return result; + } + + /** + * 获得订单 + * @param orderId + * @param userId + * @returns + */ + async getOrder(orderId: number, userId: number) { + const order: OrderInfoEntity = await this.orderInfoService.info(orderId); + if (!order || order.status != 0 || order.userId != userId) { + throw new CoolCommException('订单不存在或不是可以支付的状态'); + } + return order; + } + + /** + * 微信JSAPI + * @param orderId + * @param userId + * @param type 0-小程序 1-公众号 2-App + * @returns + */ + async wxJSAPI(orderId: number, userId: number, type = 0) { + const order = await this.getOrder(orderId, userId); + const openid = await this.userWxService.getOpenid(userId, type); + + const appid = await this.getAppidByType(type); + + const { config, instance } = await this.wxPayInstance(appid); + + const params = { + description: '商品采购', + out_trade_no: order.orderNum, + notify_url: config.notify_url, + amount: { + total: new BigNumber(order.price) + .minus(order.discountPrice || 0) + .multipliedBy(100) + .toNumber(), + }, + payer: { + openid, + }, + scene_info: { + payer_client_ip: '127.0.0.1', + }, + }; + const result = await instance.transactions_jsapi(params); + return result; + } + + /** + * 微信退款 + * @param order + * @param amount + * @returns + */ + async wxRefund(order: OrderInfoEntity, amount: number) { + const appid = await this.getAppidByType(order.wxType); + const { config, instance } = await this.wxPayInstance(appid); + const params = { + out_trade_no: order.orderNum, + out_refund_no: order.refund.orderNum, + notify_url: config.notify_url, + amount: { + refund: new BigNumber(amount).multipliedBy(100).toNumber(), + total: new BigNumber(order.price).multipliedBy(100).toNumber(), + currency: 'CNY', + }, + }; + const result = await instance.refunds(params); + if ( + result.status == 200 || + result.status == 'SUCCESS' || + result.status == 'PROCESSING' + ) { + return true; + } + throw new CoolCommException(result.message); + } +} diff --git a/src/modules/plugin/config.ts b/src/modules/plugin/config.ts new file mode 100644 index 0000000..74aa56b --- /dev/null +++ b/src/modules/plugin/config.ts @@ -0,0 +1,27 @@ +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 模块配置 + */ +export default options => { + return { + // 模块名称 + name: '插件模块', + // 模块描述 + description: '插件查看、安装、卸载、配置等', + // 中间件,只对本模块有效 + middlewares: [], + // 中间件,全局有效 + globalMiddlewares: [], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 0, + // 基础插件配置 + hooks: { + // 文件上传 + upload: { + // 地址前缀 + domain: `http://127.0.0.1:${options?.app?.getConfig('koa.port')}`, + }, + }, + } as ModuleConfig; +}; diff --git a/src/modules/plugin/controller/admin/info.ts b/src/modules/plugin/controller/admin/info.ts new file mode 100644 index 0000000..f8c9e15 --- /dev/null +++ b/src/modules/plugin/controller/admin/info.ts @@ -0,0 +1,56 @@ +import { + CoolController, + BaseController, + CoolTag, + CoolUrlTag, + TagTypes, +} from '@cool-midway/core'; +import { PluginInfoEntity } from '../../entity/info'; +import { Body, Fields, Files, Inject, Post } from '@midwayjs/core'; +import { PluginService } from '../../service/info'; + +/** + * 插件信息 + */ +@CoolUrlTag({ + key: TagTypes.IGNORE_TOKEN, + value: [], +}) +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: PluginInfoEntity, + service: PluginService, + pageQueryOp: { + select: [ + 'a.id', + 'a.name', + 'a.keyName', + 'a.hook', + 'a.version', + 'a.status', + 'a.readme', + 'a.author', + 'a.logo', + 'a.description', + 'a.pluginJson', + 'a.config', + 'a.createTime', + 'a.updateTime', + ], + addOrderBy: { + id: 'DESC', + }, + }, +}) +export class AdminPluginInfoController extends BaseController { + @Inject() + pluginService: PluginService; + + @CoolTag(TagTypes.IGNORE_TOKEN) + @Post('/install', { summary: '安装插件' }) + async install(@Files() files, @Fields() fields) { + return this.ok( + await this.pluginService.install(files[0].data, fields.force) + ); + } +} diff --git a/src/modules/plugin/entity/info.ts b/src/modules/plugin/entity/info.ts new file mode 100644 index 0000000..fddebc8 --- /dev/null +++ b/src/modules/plugin/entity/info.ts @@ -0,0 +1,50 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, DataSource, Index } from 'typeorm'; + +console.log(DataSource); + +/** + * 插件信息 + */ +@Entity('plugin_info') +export class PluginInfoEntity extends BaseEntity { + @Column({ comment: '名称' }) + name: string; + + @Column({ comment: '简介' }) + description: string; + + @Index() + @Column({ comment: 'Key名' }) + keyName: string; + + @Column({ comment: 'Hook' }) + hook: string; + + @Column({ comment: '描述', type: 'text' }) + readme: string; + + @Column({ comment: '版本' }) + version: string; + + @Column({ comment: 'Logo(base64)', type: 'text', nullable: true }) + logo: string; + + @Column({ comment: '作者' }) + author: string; + + @Column({ comment: '状态 0-禁用 1-启用', default: 0 }) + status: number; + + @Column({ comment: '内容', type: 'json' }) + content: { + type: 'comm' | 'module'; + data: string; + }; + + @Column({ comment: '插件的plugin.json', type: 'json', nullable: true }) + pluginJson: any; + + @Column({ comment: '配置', type: 'json', nullable: true }) + config: any; +} diff --git a/src/modules/plugin/event/app.ts b/src/modules/plugin/event/app.ts new file mode 100644 index 0000000..bf8d80c --- /dev/null +++ b/src/modules/plugin/event/app.ts @@ -0,0 +1,39 @@ +import { CoolEvent, Event } from '@cool-midway/core'; +import { + App, + Config, + ILogger, + Inject, + InjectClient, + Logger, +} from '@midwayjs/core'; +import { IMidwayKoaApplication } from '@midwayjs/koa'; +import { PLUGIN_CACHE_KEY, PluginCenterService } from '../service/center'; +import { CachingFactory, MidwayCache } from '@midwayjs/cache-manager'; + +/** + * 插件事件 + */ +@CoolEvent() +export class PluginAppEvent { + @Logger() + coreLogger: ILogger; + + @Config('module') + config; + + @App() + app: IMidwayKoaApplication; + + @InjectClient(CachingFactory, 'default') + midwayCache: MidwayCache; + + @Inject() + pluginCenterService: PluginCenterService; + + @Event('onServerReady') + async onServerReady() { + await this.midwayCache.set(PLUGIN_CACHE_KEY, []); + this.pluginCenterService.init(); + } +} diff --git a/src/modules/plugin/event/init.ts b/src/modules/plugin/event/init.ts new file mode 100644 index 0000000..4a55409 --- /dev/null +++ b/src/modules/plugin/event/init.ts @@ -0,0 +1,36 @@ +import { CoolEvent, Event } from '@cool-midway/core'; +import { Inject } from '@midwayjs/core'; +import { PluginCenterService } from '../service/center'; + +// 插件初始化全局事件 +export const GLOBAL_EVENT_PLUGIN_INIT = 'globalPluginInit'; +// 插件移除全局事件 +export const GLOBAL_EVENT_PLUGIN_REMOVE = 'globalPluginRemove'; + +/** + * 接收事件 + */ +@CoolEvent() +export class PluginInitEvent { + @Inject() + pluginCenterService: PluginCenterService; + + /** + * 插件初始化事件,某个插件重新初始化 + * @param key + */ + @Event(GLOBAL_EVENT_PLUGIN_INIT) + async globalPluginInit(key: string) { + await this.pluginCenterService.initOne(key); + } + + /** + * 插件移除或者关闭事件 + * @param key + * @param isHook + */ + @Event(GLOBAL_EVENT_PLUGIN_REMOVE) + async globalPluginRemove(key: string, isHook: boolean) { + await this.pluginCenterService.remove(key, isHook); + } +} diff --git a/src/modules/plugin/hooks/base.ts b/src/modules/plugin/hooks/base.ts new file mode 100644 index 0000000..05aa206 --- /dev/null +++ b/src/modules/plugin/hooks/base.ts @@ -0,0 +1,26 @@ +import { IMidwayContext, IMidwayApplication } from '@midwayjs/core'; +import { PluginInfo } from '../interface'; + +/** + * hook基类 + */ +export class BasePluginHook { + /** 请求上下文,用到此项无法本地调试,需安装到cool-admin中才能调试 */ + ctx: IMidwayContext; + /** 应用实例,用到此项无法本地调试,需安装到cool-admin中才能调试 */ + app: IMidwayApplication; + /** 插件信息 */ + pluginInfo: PluginInfo; + /** + * 初始化 + */ + async init( + pluginInfo: PluginInfo, + ctx?: IMidwayContext, + app?: IMidwayApplication + ) { + this.pluginInfo = pluginInfo; + this.ctx = ctx; + this.app = app; + } +} diff --git a/src/modules/plugin/hooks/upload/index.ts b/src/modules/plugin/hooks/upload/index.ts new file mode 100644 index 0000000..003bec7 --- /dev/null +++ b/src/modules/plugin/hooks/upload/index.ts @@ -0,0 +1,122 @@ +import { BaseUpload, MODETYPE } from './interface'; +import { BasePluginHook } from '../base'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as moment from 'moment'; +import { v1 as uuid } from 'uuid'; +import { CoolCommException } from '@cool-midway/core'; +import * as _ from 'lodash'; + +/** + * 文件上传 + */ +export class PluginUpload extends BasePluginHook implements BaseUpload { + /** + * 获得上传模式 + * @returns + */ + async getMode() { + return { + mode: MODETYPE.LOCAL, + type: MODETYPE.LOCAL, + }; + } + + /** + * 获得原始操作对象 + * @returns + */ + async getMetaFileObj() { + return; + } + + /** + * 下载并上传 + * @param url + * @param fileName + */ + async downAndUpload(url: string, fileName?: string) { + const { domain } = this.pluginInfo.config; + // 从url获取扩展名 + const extend = path.extname(url); + const download = require('download'); + // 数据 + const data = url.includes('http') + ? await download(url) + : fs.readFileSync(url); + // 创建文件夹 + const dirPath = path.join( + this.app.getBaseDir(), + '..', + `public/uploads/${moment().format('YYYYMMDD')}` + ); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + const uuidStr = uuid(); + const name = `uploads/${moment().format('YYYYMMDD')}/${ + fileName ? fileName : uuidStr + extend + }`; + fs.writeFileSync( + `${dirPath}/${fileName ? fileName : uuid() + extend}`, + data + ); + return `${domain}/public/${name}`; + } + + /** + * 指定Key(路径)上传,本地文件上传到存储服务 + * @param filePath 文件路径 + * @param key 路径一致会覆盖源文件 + */ + async uploadWithKey(filePath: any, key: any) { + const { domain } = this.pluginInfo.config; + const data = fs.readFileSync(filePath); + fs.writeFileSync(path.join(this.app.getBaseDir(), '..', key), data); + return domain + key; + } + + /** + * 上传文件 + * @param ctx + * @param key 文件路径 + */ + async upload(ctx: any) { + const { domain } = this.pluginInfo.config; + try { + const { key } = ctx.fields; + if (_.isEmpty(ctx.files)) { + throw new CoolCommException('上传文件为空'); + } + // 检查public/uploads文件夹是否存在,不存在则创建 + const basePath = path.join( + this.app.getBaseDir(), + '..', + 'public', + 'uploads' + ); + if (!fs.existsSync(basePath)) { + fs.mkdirSync(basePath); + } + + const file = ctx.files[0]; + const extension = file.filename.split('.').pop(); + const name = + moment().format('YYYYMMDD') + '/' + (key || `${uuid()}.${extension}`); + const target = path.join(basePath, name); + const dirPath = path.join(basePath, moment().format('YYYYMMDD')); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath); + } + const data = fs.readFileSync(file.data); + fs.writeFileSync(target, data); + return domain + '/public/uploads/' + name; + } catch (err) { + console.error(err); + throw new CoolCommException('上传失败' + err.message); + } + } +} + +// 导出插件实例, Plugin名称不可修改 +export const Plugin = PluginUpload; diff --git a/src/modules/plugin/hooks/upload/interface.ts b/src/modules/plugin/hooks/upload/interface.ts new file mode 100644 index 0000000..4f0cdfc --- /dev/null +++ b/src/modules/plugin/hooks/upload/interface.ts @@ -0,0 +1,56 @@ +// 模式 +export enum MODETYPE { + // 本地 + LOCAL = 'local', + // 云存储 + CLOUD = 'cloud', + // 其他 + OTHER = 'other', +} + +/** + * 上传模式 + */ +export interface Mode { + // 模式 + mode: MODETYPE; + // 类型 + type: string; +} + +/** + * 文件上传 + */ +export interface BaseUpload { + /** + * 获得上传模式 + */ + getMode(): Promise; + + /** + * 获得原始操作对象 + * @returns + */ + getMetaFileObj(): Promise; + + /** + * 下载并上传 + * @param url + * @param fileName 文件名 + */ + downAndUpload(url: string, fileName?: string): Promise; + + /** + * 指定Key(路径)上传,本地文件上传到存储服务 + * @param filePath 文件路径 + * @param key 路径一致会覆盖源文件 + */ + uploadWithKey(filePath, key): Promise; + + /** + * 上传文件 + * @param ctx + * @param key 文件路径 + */ + upload(ctx): Promise; +} diff --git a/src/modules/plugin/interface.ts b/src/modules/plugin/interface.ts new file mode 100644 index 0000000..5af915d --- /dev/null +++ b/src/modules/plugin/interface.ts @@ -0,0 +1,25 @@ +/** + * 插件信息 + */ +export interface PluginInfo { + /** 名称 */ + name?: string; + /** 唯一标识 */ + key?: string; + /** 钩子 */ + hook?: string; + /** 是否单例 */ + singleton?: boolean; + /** 版本 */ + version?: string; + /** 描述 */ + description?: string; + /** 作者 */ + author?: string; + /** logo */ + logo?: string; + /** README 使用说明 */ + readme?: string; + /** 配置 */ + config?: any; +} diff --git a/src/modules/plugin/service/center.ts b/src/modules/plugin/service/center.ts new file mode 100644 index 0000000..3098c24 --- /dev/null +++ b/src/modules/plugin/service/center.ts @@ -0,0 +1,196 @@ +import { Provide } from '@midwayjs/decorator'; +import { + App, + IMidwayApplication, + Inject, + InjectClient, + Scope, + ScopeEnum, +} from '@midwayjs/core'; +import * as fs from 'fs'; +import * as path from 'path'; +import { PluginInfoEntity } from '../entity/info'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { PluginInfo } from '../interface'; +import * as _ from 'lodash'; +import { CachingFactory, MidwayCache } from '@midwayjs/cache-manager'; +import { CoolEventManager } from '@cool-midway/core'; +import { PluginService } from './info'; + +export const PLUGIN_CACHE_KEY = 'plugin:init'; + +export const EVENT_PLUGIN_READY = 'EVENT_PLUGIN_READY'; + +/** + * 插件中心 + */ +@Provide() +@Scope(ScopeEnum.Singleton) +export class PluginCenterService { + // 插件列表 + plugins: Map = new Map(); + + // 插件配置 + pluginInfos: Map = new Map(); + + @App() + app: IMidwayApplication; + + @InjectEntityModel(PluginInfoEntity) + pluginInfoEntity: Repository; + + @InjectClient(CachingFactory, 'default') + midwayCache: MidwayCache; + + @Inject() + coolEventManager: CoolEventManager; + + @Inject() + pluginService: PluginService; + + /** + * 初始化 + * @returns + */ + async init() { + this.plugins.clear(); + await this.initHooks(); + await this.initPlugin(); + this.coolEventManager.emit(EVENT_PLUGIN_READY); + } + + /** + * 初始化一个 + * @param keyName key名 + */ + async initOne(keyName: string) { + await this.initPlugin({ + keyName, + }); + this.coolEventManager.emit(EVENT_PLUGIN_READY, keyName); + } + + /** + * 移除插件 + * @param keyName + * @param isHook + */ + async remove(keyName: string, isHook = false) { + this.plugins.delete(keyName); + this.pluginInfos.delete(keyName); + if (isHook) { + await this.initHooks(); + } + } + + /** + * 注册插件 + * @param key 唯一标识 + * @param cls 类 + * @param pluginInfo 插件信息 + */ + async register(key: string, cls: any, pluginInfo?: PluginInfo) { + // 单例插件 + if (pluginInfo?.singleton) { + const instance = new cls(); + await instance.init(this.pluginInfos.get(key), null, this.app, { + cache: this.midwayCache, + pluginService: this.pluginService, + }); + this.plugins.set(key, instance); + } else { + // 普通插件 + this.plugins.set(key, cls); + } + } + + /** + * 初始化钩子 + */ + async initHooks() { + const hooksPath = path.join( + this.app.getBaseDir(), + 'modules', + 'plugin', + 'hooks' + ); + for (const key of fs.readdirSync(hooksPath)) { + const stat = fs.statSync(path.join(hooksPath, key)); + if (!stat.isDirectory()) { + continue; + } + const { Plugin } = await import(path.join(hooksPath, key, 'index')); + await this.register(key, Plugin); + this.pluginInfos.set(key, { + name: key, + config: this.app.getConfig('module.plugin.hooks.' + key), + }); + } + } + + /** + * 初始化插件 + * @param condition 插件条件 + */ + async initPlugin(condition?: { + hook?: string; + id?: number; + keyName?: string; + }) { + let find: any = { status: 1 }; + if (condition) { + find = { + ...find, + ...condition, + }; + } + const plugins = await this.pluginInfoEntity.findBy(find); + for (const plugin of plugins) { + const instance = await this.getInstance(plugin.content.data); + const pluginInfo = { + ...plugin.pluginJson, + config: this.getConfig(plugin.config), + }; + if (plugin.hook) { + this.pluginInfos.set(plugin.hook, pluginInfo); + await this.register(plugin.hook, instance, pluginInfo); + } else { + this.pluginInfos.set(plugin.keyName, pluginInfo); + await this.register(plugin.keyName, instance, pluginInfo); + } + } + } + + /** + * 获得配置 + * @param config + * @returns + */ + private getConfig(config: any) { + const env = this.app.getEnv(); + let isMulti = false; + for (const key in config) { + if (key.includes('@')) { + isMulti = true; + break; + } + } + return isMulti ? config[`@${env}`] : config; + } + + /** + * 获得实例 + * @param content + * @returns + */ + async getInstance(content: string) { + let _instance; + const script = ` + ${content} + _instance = Plugin; + `; + eval(script); + return _instance; + } +} diff --git a/src/modules/plugin/service/info.ts b/src/modules/plugin/service/info.ts new file mode 100644 index 0000000..e6aa16a --- /dev/null +++ b/src/modules/plugin/service/info.ts @@ -0,0 +1,309 @@ +import { App, Inject, Provide } from '@midwayjs/decorator'; +import { + BaseService, + CoolCommException, + CoolEventManager, +} from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Equal, In, Not, Repository } from 'typeorm'; +import { PluginInfoEntity } from '../entity/info'; +import { + Config, + IMidwayApplication, + IMidwayContext, + InjectClient, +} from '@midwayjs/core'; +import * as _ from 'lodash'; +import { PluginInfo } from '../interface'; +import { PluginCenterService } from './center'; +import { CachingFactory, MidwayCache } from '@midwayjs/cache-manager'; +import { + GLOBAL_EVENT_PLUGIN_INIT, + GLOBAL_EVENT_PLUGIN_REMOVE, +} from '../event/init'; + +/** + * 插件信息 + */ +@Provide() +export class PluginService extends BaseService { + @InjectEntityModel(PluginInfoEntity) + pluginInfoEntity: Repository; + + @Inject() + ctx: IMidwayContext; + + @App() + app: IMidwayApplication; + + @Inject() + pluginCenterService: PluginCenterService; + + @Config('module.plugin.hooks') + hooksConfig; + + @InjectClient(CachingFactory, 'default') + midwayCache: MidwayCache; + + @Inject() + coolEventManager: CoolEventManager; + + /** + * 新增或更新 + * @param param + * @param type + */ + async addOrUpdate(param: any, type?: 'add' | 'update') { + await super.addOrUpdate(param, type); + const info = await this.pluginInfoEntity.findOneBy({ id: param.id }); + if (info.status == 1) { + await this.reInit(info.keyName); + } else { + await this.remove(info.keyName, !!info.hook); + } + } + + /** + * 重新初始化插件 + */ + async reInit(keyName: string) { + // 多进程发送全局事件,pm2下生效,本地开发则通过普通事件 + this.coolEventManager.globalEmit(GLOBAL_EVENT_PLUGIN_INIT, false, keyName); + } + + /** + * 移除插件 + * @param keyName + * @param isHook + */ + async remove(keyName: string, isHook = false) { + // 多进程发送全局事件,pm2下生效 + this.coolEventManager.globalEmit( + GLOBAL_EVENT_PLUGIN_REMOVE, + false, + keyName, + isHook + ); + } + + /** + * 删除不经过回收站 + * @param ids + */ + async delete(ids: any) { + const list = await this.pluginInfoEntity.findBy({ id: In(ids) }); + for (const item of list) { + await this.remove(item.keyName, !!item.hook); + } + await this.pluginInfoEntity.delete(ids); + } + + /** + * 更新 + * @param param + */ + async update(param: any) { + const old = await this.pluginInfoEntity.findOneBy({ id: param.id }); + // 启用插件,禁用同名插件 + if (old.hook && param.status == 1 && old.status != param.status) { + await this.pluginInfoEntity.update( + { hook: old.hook, status: 1, id: Not(old.id) }, + { status: 0 } + ); + } + await super.update(param); + } + + /** + * 获得插件配置 + * @param key + */ + async getConfig(key: string) { + return this.pluginCenterService.pluginInfos.get(key)?.config; + } + + /** + * 调用插件 + * @param key 插件key + * @param method 方法 + * @param params 参数 + * @returns + */ + async invoke(key: string, method: string, ...params) { + // 实例 + const instance = await this.getInstance(key); + return await instance[method](...params); + } + + /** + * 获得插件实例 + * @param key + * @returns + */ + async getInstance(key: string) { + const check = await this.checkStatus(key); + if (!check) throw new CoolCommException(`插件[${key}]不存在或已禁用`); + let instance; + const pluginInfo = this.pluginCenterService.pluginInfos.get(key); + if (pluginInfo.singleton) { + instance = this.pluginCenterService.plugins.get(key); + } else { + instance = new (await this.pluginCenterService.plugins.get(key))(); + await instance.init(pluginInfo, this.ctx, this.app, { + cache: this.midwayCache, + pluginService: this, + }); + } + return instance; + } + + /** + * 检查状态 + * @param key + */ + async checkStatus(key: string) { + if (Object.keys(this.hooksConfig).includes(key)) { + return true; + } + const info = await this.pluginInfoEntity + .createQueryBuilder('a') + .select(['a.id', 'a.status']) + .where({ status: 1, keyName: Equal(key) }) + .getOne(); + + return !!info; + } + + /** + * 检查 + * @param filePath + */ + async check(filePath: string) { + let data; + try { + data = await this.data(filePath); + } catch (e) { + return { + type: 0, + message: `插件信息不完整,请检查${data.errorData}`, + }; + } + const check = await this.pluginInfoEntity.findOneBy({ + keyName: data.pluginJson.key, + }); + if (check && !check.hook) { + return { + type: 1, + message: '插件已存在,继续安装将覆盖', + }; + } + if (check && check.hook && check.status == 1) { + return { + type: 2, + message: + '已存在同名Hook插件,你可以继续安装,但是多个相同的Hook插件只能同时开启一个', + }; + } + return { + type: 3, + message: '检查通过', + }; + } + + /** + * 获得插件数据 + * @param filePath + */ + async data(filePath: string): Promise<{ + pluginJson: any; + readme: string; + logo: string; + content: string; + errorData: string; + }> { + // const plugin = await download(encodeURI(url)); + const decompress = require('decompress'); + const files = await decompress(filePath); + let errorData; + let pluginJson: PluginInfo, readme: string, logo: string, content: string; + try { + errorData = 'plugin.json'; + pluginJson = JSON.parse( + _.find(files, { path: 'plugin.json', type: 'file' }).data.toString() + ); + errorData = 'readme'; + readme = _.find(files, { + path: pluginJson.readme, + type: 'file', + }).data.toString(); + errorData = 'logo'; + logo = _.find(files, { + path: pluginJson.logo, + type: 'file', + }).data.toString('base64'); + content = _.find(files, { + path: 'src/index.js', + type: 'file', + }).data.toString(); + } catch (e) { + throw new CoolCommException('插件信息不完整'); + } + return { + pluginJson, + readme, + logo, + content, + errorData, + }; + } + + /** + * 安装插件 + * @param file 文件 + * @param force 是否强制安装 + */ + async install(filePath: string, force = false) { + const forceBool = typeof force === 'string' ? force === 'true' : force; + const checkResult = await this.check(filePath); + if (checkResult.type != 3 && !forceBool) { + return checkResult; + } + const { pluginJson, readme, logo, content } = await this.data(filePath); + const check = await this.pluginInfoEntity.findOneBy({ + keyName: pluginJson.key, + }); + const data = { + name: pluginJson.name, + keyName: pluginJson.key, + version: pluginJson.version, + author: pluginJson.author, + hook: pluginJson.hook, + readme, + logo, + content: { + type: 'comm', + data: content, + }, + description: pluginJson.description, + pluginJson, + config: pluginJson.config, + status: 1, + } as PluginInfoEntity; + // 存在同名插件,更新,保留配置 + if (check) { + await this.pluginInfoEntity.update(check.id, { + ...data, + status: check.status, + config: { + ...pluginJson.config, + ...check.config, + }, + }); + } else { + // 全新安装 + await this.pluginInfoEntity.insert(data); + } + // 初始化插件 + await this.reInit(pluginJson.key); + } +} diff --git a/src/modules/recycle/config.ts b/src/modules/recycle/config.ts new file mode 100644 index 0000000..3421900 --- /dev/null +++ b/src/modules/recycle/config.ts @@ -0,0 +1,19 @@ +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 模块配置 + */ +export default () => { + return { + // 模块名称 + name: '数据回收', + // 模块描述 + description: '收集被删除的数据,管理和恢复', + // 中间件,只对本模块有效 + middlewares: [], + // 中间件,全局有效 + globalMiddlewares: [], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 0, + } as ModuleConfig; +}; diff --git a/src/modules/recycle/controller/admin/data.ts b/src/modules/recycle/controller/admin/data.ts new file mode 100644 index 0000000..926cd5e --- /dev/null +++ b/src/modules/recycle/controller/admin/data.ts @@ -0,0 +1,35 @@ +import { BaseSysUserEntity } from './../../../base/entity/sys/user'; +import { RecycleDataEntity } from './../../entity/data'; +import { Body, Inject, Post, Provide } from '@midwayjs/decorator'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { RecycleDataService } from '../../service/data'; + +/** + * 数据回收 + */ +@Provide() +@CoolController({ + api: ['info', 'page'], + entity: RecycleDataEntity, + pageQueryOp: { + keyWordLikeFields: ['b.name', 'a.url'], + select: ['a.*', 'b.name as userName'], + join: [ + { + entity: BaseSysUserEntity, + alias: 'b', + condition: 'a.userId = b.id', + }, + ], + }, +}) +export class AdminRecycleDataController extends BaseController { + @Inject() + recycleDataService: RecycleDataService; + + @Post('/restore', { summary: '恢复数据' }) + async restore(@Body('ids') ids: number[]) { + await this.recycleDataService.restore(ids); + return this.ok(); + } +} diff --git a/src/modules/recycle/entity/data.ts b/src/modules/recycle/entity/data.ts new file mode 100644 index 0000000..e150f43 --- /dev/null +++ b/src/modules/recycle/entity/data.ts @@ -0,0 +1,32 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Entity, Column, Index } from 'typeorm'; + +/** + * 数据回收站 软删除的时候数据会回收到该表 + */ +@Entity('recycle_data') +export class RecycleDataEntity extends BaseEntity { + @Column({ comment: '表', type: 'json' }) + entityInfo: { + // 数据源名称 + dataSourceName: string; + // entity + entity: string; + }; + + @Index() + @Column({ comment: '操作人', nullable: true }) + userId: number; + + @Column({ comment: '被删除的数据', type: 'json' }) + data: object[]; + + @Column({ comment: '请求的接口', nullable: true }) + url: string; + + @Column({ comment: '请求参数', nullable: true, type: 'json' }) + params: string; + + @Column({ comment: '删除数据条数', default: 1 }) + count: number; +} diff --git a/src/modules/recycle/event/data.ts b/src/modules/recycle/event/data.ts new file mode 100644 index 0000000..5c96bab --- /dev/null +++ b/src/modules/recycle/event/data.ts @@ -0,0 +1,21 @@ +import { CoolEvent, EVENT, Event } from '@cool-midway/core'; +import { Inject } from '@midwayjs/core'; +import { RecycleDataService } from '../service/data'; + +/** + * 接受数据事件 + */ +@CoolEvent() +export class RecycleDataEvent { + @Inject() + recycleDataService: RecycleDataService; + + /** + * 数据被删除 + * @param params + */ + @Event(EVENT.SOFT_DELETE) + async softDelete(params) { + await this.recycleDataService.record(params); + } +} diff --git a/src/modules/recycle/schedule/data.ts b/src/modules/recycle/schedule/data.ts new file mode 100644 index 0000000..5ac87b9 --- /dev/null +++ b/src/modules/recycle/schedule/data.ts @@ -0,0 +1,32 @@ +import { + Provide, + Inject, + CommonSchedule, + TaskLocal, + FORMAT, +} from '@midwayjs/decorator'; +import { ILogger } from '@midwayjs/logger'; +import { RecycleDataService } from '../service/data'; + +/** + * 数据定时清除定时任务 + */ +@Provide() +export class BaseRecycleSchedule implements CommonSchedule { + @Inject() + recycleDataService: RecycleDataService; + + @Inject() + logger: ILogger; + + // 定时执行的具体任务 + @TaskLocal(FORMAT.CRONTAB.EVERY_DAY) + async exec() { + this.logger.info('清除回收站数据定时任务开始执行'); + const startTime = Date.now(); + await this.recycleDataService.clear(); + this.logger.info( + `清除回收站数据定时任务结束,耗时:${Date.now() - startTime}ms` + ); + } +} diff --git a/src/modules/recycle/service/data.ts b/src/modules/recycle/service/data.ts new file mode 100644 index 0000000..300ce36 --- /dev/null +++ b/src/modules/recycle/service/data.ts @@ -0,0 +1,83 @@ +import { RecycleDataEntity } from './../entity/data'; +import { Inject, Provide, Scope, ScopeEnum } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel, TypeORMDataSourceManager } from '@midwayjs/typeorm'; +import { LessThan, Repository } from 'typeorm'; +import * as _ from 'lodash'; +import * as moment from 'moment'; +import { BaseSysConfService } from '../../base/service/sys/conf'; + +/** + * 数据回收 + */ +@Provide() +@Scope(ScopeEnum.Request, { allowDowngrade: true }) +export class RecycleDataService extends BaseService { + @InjectEntityModel(RecycleDataEntity) + recycleDataEntity: Repository; + + @Inject() + typeORMDataSourceManager: TypeORMDataSourceManager; + + @Inject() + baseSysConfService: BaseSysConfService; + + /** + * 恢复数据 + * @param ids + */ + async restore(ids: number[]) { + for (const id of ids) { + const info = await this.recycleDataEntity.findOneBy({ id }); + if (!info) { + continue; + } + let entityModel = this.typeORMDataSourceManager + .getDataSource(info.entityInfo.dataSourceName) + .getRepository(info.entityInfo.entity); + await entityModel.save(info.data); + await this.recycleDataEntity.delete(id); + } + } + + /** + * 记录数据 + * @param params + */ + async record(params) { + const { ctx, data, entity } = params; + const dataSourceName = + this.typeORMDataSourceManager.getDataSourceNameByModel(entity.target); + const url = ctx?.url; + await this.recycleDataEntity.save({ + entityInfo: { + dataSourceName, + entity: entity.target.name, + }, + url, + params: + ctx?.req.method === 'GET' ? ctx?.request.query : ctx?.request.body, + data, + count: data.length, + userId: _.startsWith(url, '/admin/') ? ctx?.admin.userId : ctx?.user?.id, + }); + } + + /** + * 日志 + * @param isAll 是否清除全部 + */ + async clear(isAll?) { + if (isAll) { + await this.recycleDataEntity.clear(); + return; + } + const keepDay = await this.baseSysConfService.getValue('recycleKeep'); + if (keepDay) { + const beforeDate = moment().add(-keepDay, 'days').startOf('day').toDate(); + await this.recycleDataEntity.delete({ createTime: LessThan(beforeDate) }); + } else { + await this.recycleDataEntity.clear(); + } + } +} diff --git a/src/modules/space/config.ts b/src/modules/space/config.ts new file mode 100644 index 0000000..10e7cff --- /dev/null +++ b/src/modules/space/config.ts @@ -0,0 +1,24 @@ +import { ModuleConfig } from '@cool-midway/core'; + +/** + * 模块配置 + */ +export default () => { + return { + // 模块名称 + name: '文件空间', + // 模块描述 + description: '上传和管理文件资源', + // 中间件,只对本模块有效 + middlewares: [], + // 中间件,全局有效 + globalMiddlewares: [], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 0, + // wps的配置 + wps: { + // 这是个测试的appId,会有水印 + appId: 'SX20230111NDUAGQ', + }, + } as ModuleConfig; +}; diff --git a/src/modules/space/controller/admin/info.ts b/src/modules/space/controller/admin/info.ts new file mode 100644 index 0000000..11f3a07 --- /dev/null +++ b/src/modules/space/controller/admin/info.ts @@ -0,0 +1,18 @@ +import { Provide } from '@midwayjs/decorator'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { SpaceInfoEntity } from '../../entity/info'; +import { SpaceInfoService } from '../../service/info'; + +/** + * 图片空间信息 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: SpaceInfoEntity, + service: SpaceInfoService, + pageQueryOp: { + fieldEq: ['type', 'classifyId'], + }, +}) +export class BaseAppSpaceInfoController extends BaseController {} diff --git a/src/modules/space/controller/admin/type.ts b/src/modules/space/controller/admin/type.ts new file mode 100644 index 0000000..6429242 --- /dev/null +++ b/src/modules/space/controller/admin/type.ts @@ -0,0 +1,15 @@ +import { Provide } from '@midwayjs/decorator'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { SpaceTypeEntity } from '../../entity/type'; +import { SpaceTypeService } from '../../service/type'; + +/** + * 空间分类 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: SpaceTypeEntity, + service: SpaceTypeService, +}) +export class BaseAppSpaceTypeController extends BaseController {} diff --git a/src/modules/space/controller/说明.md b/src/modules/space/controller/说明.md new file mode 100644 index 0000000..440a373 --- /dev/null +++ b/src/modules/space/controller/说明.md @@ -0,0 +1 @@ +编写接口 \ No newline at end of file diff --git a/src/modules/space/entity/info.ts b/src/modules/space/entity/info.ts new file mode 100644 index 0000000..3c2d4fc --- /dev/null +++ b/src/modules/space/entity/info.ts @@ -0,0 +1,33 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Index, Entity } from 'typeorm'; + +/** + * 文件空间信息 + */ +@Entity('space_info') +export class SpaceInfoEntity extends BaseEntity { + @Column({ comment: '地址' }) + url: string; + + @Column({ comment: '类型' }) + type: string; + + @Column({ comment: '分类ID', nullable: true }) + classifyId: number; + + @Index() + @Column({ comment: '文件id' }) + fileId: string; + + @Column({ comment: '文件名' }) + name: string; + + @Column({ comment: '文件大小' }) + size: number; + + @Column({ comment: '文档版本', default: 1 }) + version: number; + + @Column({ comment: '文件位置' }) + key: string; +} diff --git a/src/modules/space/entity/type.ts b/src/modules/space/entity/type.ts new file mode 100644 index 0000000..15c4308 --- /dev/null +++ b/src/modules/space/entity/type.ts @@ -0,0 +1,14 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity } from 'typeorm'; + +/** + * 图片空间信息分类 + */ +@Entity('space_type') +export class SpaceTypeEntity extends BaseEntity { + @Column({ comment: '类别名称' }) + name: string; + + @Column({ comment: '父分类ID', nullable: true }) + parentId: number; +} diff --git a/src/modules/space/service/info.ts b/src/modules/space/service/info.ts new file mode 100644 index 0000000..417588a --- /dev/null +++ b/src/modules/space/service/info.ts @@ -0,0 +1,30 @@ +import { SpaceInfoEntity } from './../entity/info'; +import { Inject, Provide } from '@midwayjs/decorator'; +import { BaseService, MODETYPE } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { PluginService } from '../../plugin/service/info'; + +/** + * 文件信息 + */ +@Provide() +export class SpaceInfoService extends BaseService { + @InjectEntityModel(SpaceInfoEntity) + spaceInfoEntity: Repository; + + @Inject() + pluginService: PluginService; + + /** + * 新增 + */ + async add(param) { + const result = await this.pluginService.invoke('upload', 'getMode'); + const config = await this.pluginService.getConfig('upload'); + if (result.mode == MODETYPE.LOCAL) { + param.key = param.url.replace(config.domain, ''); + } + return super.add(param); + } +} diff --git a/src/modules/space/service/type.ts b/src/modules/space/service/type.ts new file mode 100644 index 0000000..a9a6704 --- /dev/null +++ b/src/modules/space/service/type.ts @@ -0,0 +1,28 @@ +import { Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { SpaceTypeEntity } from '../entity/type'; +import { SpaceInfoEntity } from '../entity/info'; + +/** + * 文件分类 + */ +@Provide() +export class SpaceTypeService extends BaseService { + @InjectEntityModel(SpaceTypeEntity) + spaceTypeEntity: Repository; + + @InjectEntityModel(SpaceInfoEntity) + spaceInfoEntity: Repository; + + /** + * 删除 + * @param ids + */ + async delete(ids: any) { + await super.delete(ids); + // 删除该分类下的文件信息 + await this.spaceInfoEntity.delete({ classifyId: In(ids) }); + } +} diff --git a/src/modules/task/config.ts b/src/modules/task/config.ts new file mode 100644 index 0000000..b919075 --- /dev/null +++ b/src/modules/task/config.ts @@ -0,0 +1,18 @@ +import { ModuleConfig } from '@cool-midway/core'; +import { TaskMiddleware } from './middleware/task'; + +/** + * 模块配置 + */ +export default () => { + return { + // 模块名称 + name: '任务调度', + // 模块描述 + description: '任务调度模块,支持分布式任务,由redis整个集群的任务', + // 中间件 + middlewares: [TaskMiddleware], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 0, + } as ModuleConfig; +}; diff --git a/src/modules/task/controller/admin/info.ts b/src/modules/task/controller/admin/info.ts new file mode 100644 index 0000000..bca8ee9 --- /dev/null +++ b/src/modules/task/controller/admin/info.ts @@ -0,0 +1,67 @@ +import { + ALL, + Body, + Get, + Inject, + Post, + Provide, + Query, +} from '@midwayjs/decorator'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { TaskInfoEntity } from '../../entity/info'; +import { TaskInfoService } from '../../service/info'; + +/** + * 任务 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'page'], + entity: TaskInfoEntity, + service: TaskInfoService, + before: ctx => { + ctx.request.body.limit = ctx.request.body.repeatCount; + }, + pageQueryOp: { + fieldEq: ['status', 'type'], + }, +}) +export class TaskInfoController extends BaseController { + @Inject() + taskInfoService: TaskInfoService; + + /** + * 手动执行一次 + */ + @Post('/once', { summary: '执行一次' }) + async once(@Body('id') id: number) { + await this.taskInfoService.once(id); + this.ok(); + } + + /** + * 暂停任务 + */ + @Post('/stop', { summary: '停止' }) + async stop(@Body('id') id: number) { + await this.taskInfoService.stop(id); + this.ok(); + } + + /** + * 开始任务 + */ + @Post('/start', { summary: '开始' }) + async start(@Body('id') id: number, @Body('type') type: number) { + await this.taskInfoService.start(id, type); + this.ok(); + } + + /** + * 日志 + */ + @Get('/log', { summary: '日志' }) + async log(@Query(ALL) params: any) { + return this.ok(await this.taskInfoService.log(params)); + } +} diff --git a/src/modules/task/controller/说明.md b/src/modules/task/controller/说明.md new file mode 100644 index 0000000..440a373 --- /dev/null +++ b/src/modules/task/controller/说明.md @@ -0,0 +1 @@ +编写接口 \ No newline at end of file diff --git a/src/modules/task/db.json b/src/modules/task/db.json new file mode 100644 index 0000000..681ecbc --- /dev/null +++ b/src/modules/task/db.json @@ -0,0 +1,40 @@ +{ + "task_info": [ + { + "id": 1, + "jobId": null, + "repeatConf": "{\"count\":1,\"type\":1,\"limit\":5,\"name\":\"每秒执行,总共5次\",\"taskType\":1,\"every\":1000,\"service\":\"taskDemoService.test()\",\"status\":1,\"id\":1,\"createTime\":\"2021-03-10 14:25:13\",\"updateTime\":\"2021-03-10 14:25:13\",\"jobId\":1}", + "name": "每秒执行,总共5次", + "cron": null, + "limit": 5, + "every": 1000, + "remark": null, + "status": 0, + "startDate": null, + "endDate": null, + "data": null, + "service": "taskDemoService.test()", + "type": 1, + "nextRunTime": "2021-3-10 14:25:18", + "taskType": 1 + }, + { + "id": 2, + "jobId": null, + "repeatConf": "{\"count\":1,\"id\":2,\"createTime\":\"2021-03-10 14:25:53\",\"updateTime\":\"2021-03-10 14:25:55\",\"name\":\"cron任务,5秒执行一次\",\"cron\":\"0/5 * * * * ? \",\"status\":1,\"service\":\"taskDemoService.test()\",\"type\":1,\"nextRunTime\":\"2021-03-10 14:26:00\",\"taskType\":0,\"jobId\":2}", + "name": "cron任务,5秒执行一次", + "cron": "0/5 * * * * ? ", + "limit": null, + "every": null, + "remark": null, + "status": 0, + "startDate": null, + "endDate": null, + "data": null, + "service": "taskDemoService.test()", + "type": 1, + "nextRunTime": null, + "taskType": 0 + } + ] +} \ No newline at end of file diff --git a/src/modules/task/entity/info.ts b/src/modules/task/entity/info.ts new file mode 100644 index 0000000..0393e24 --- /dev/null +++ b/src/modules/task/entity/info.ts @@ -0,0 +1,56 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity } from 'typeorm'; + +/** + * 任务信息 + */ +@Entity('task_info') +export class TaskInfoEntity extends BaseEntity { + @Column({ comment: '任务ID', nullable: true }) + jobId: string; + + @Column({ comment: '任务配置', nullable: true, length: 1000 }) + repeatConf: string; + + @Column({ comment: '名称' }) + name: string; + + @Column({ comment: 'cron', nullable: true }) + cron: string; + + @Column({ comment: '最大执行次数 不传为无限次', nullable: true }) + limit: number; + + @Column({ + comment: '每间隔多少毫秒执行一次 如果cron设置了 这项设置就无效', + nullable: true, + }) + every: number; + + @Column({ comment: '备注', nullable: true }) + remark: string; + + @Column({ comment: '状态 0-停止 1-运行', default: 1 }) + status: number; + + @Column({ comment: '开始时间', nullable: true }) + startDate: Date; + + @Column({ comment: '结束时间', nullable: true }) + endDate: Date; + + @Column({ comment: '数据', nullable: true }) + data: string; + + @Column({ comment: '执行的service实例ID', nullable: true }) + service: string; + + @Column({ comment: '状态 0-系统 1-用户', default: 0 }) + type: number; + + @Column({ comment: '下一次执行时间', nullable: true }) + nextRunTime: Date; + + @Column({ comment: '状态 0-cron 1-时间间隔', default: 0 }) + taskType: number; +} diff --git a/src/modules/task/entity/log.ts b/src/modules/task/entity/log.ts new file mode 100644 index 0000000..2f9030a --- /dev/null +++ b/src/modules/task/entity/log.ts @@ -0,0 +1,18 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Index, Entity } from 'typeorm'; + +/** + * 任务日志 + */ +@Entity('task_log') +export class TaskLogEntity extends BaseEntity { + @Index() + @Column({ comment: '任务ID', nullable: true }) + taskId: number; + + @Column({ comment: '状态 0-失败 1-成功', default: 0 }) + status: number; + + @Column({ comment: '详情描述', nullable: true, type: 'text' }) + detail: string; +} diff --git a/src/modules/task/event/app.ts b/src/modules/task/event/app.ts new file mode 100644 index 0000000..32f409a --- /dev/null +++ b/src/modules/task/event/app.ts @@ -0,0 +1,17 @@ +import { Inject } from '@midwayjs/core'; +import { TaskInfoService } from './../service/info'; +import { CoolEvent, Event } from '@cool-midway/core'; + +/** + * 应用事件 + */ +@CoolEvent() +export class AppEvent { + @Inject() + taskInfoService: TaskInfoService; + + @Event('onServerReady') + async onServerReady() { + this.taskInfoService.initTask(); + } +} diff --git a/src/modules/task/middleware/task.ts b/src/modules/task/middleware/task.ts new file mode 100644 index 0000000..8926852 --- /dev/null +++ b/src/modules/task/middleware/task.ts @@ -0,0 +1,31 @@ +import { CoolCommException } from '@cool-midway/core'; +import { Inject, Middleware } from '@midwayjs/decorator'; +import { NextFunction, Context } from '@midwayjs/koa'; +import { IMiddleware } from '@midwayjs/core'; +import { TaskInfoQueue } from '../queue/task'; + +/** + * 任务中间件 + */ +@Middleware() +export class TaskMiddleware implements IMiddleware { + @Inject() + taskInfoQueue: TaskInfoQueue; + resolve() { + return async (ctx: Context, next: NextFunction) => { + const urls = ctx.url.split('/'); + if ( + ['add', 'update', 'once', 'stop', 'start'].includes( + urls[urls.length - 1] + ) + ) { + if (!this.taskInfoQueue.metaQueue) { + throw new CoolCommException( + 'task插件未启用或redis配置错误或redis版本过低(>=6.x)' + ); + } + } + await next(); + }; + } +} diff --git a/src/modules/task/queue/task.ts b/src/modules/task/queue/task.ts new file mode 100644 index 0000000..57a713e --- /dev/null +++ b/src/modules/task/queue/task.ts @@ -0,0 +1,30 @@ +import { App, Inject } from '@midwayjs/decorator'; +import { BaseCoolQueue, CoolQueue } from '@cool-midway/task'; +import { TaskInfoService } from '../service/info'; +import { IMidwayApplication } from '@midwayjs/core'; + +/** + * 任务 + */ +@CoolQueue() +export abstract class TaskInfoQueue extends BaseCoolQueue { + @App() + app: IMidwayApplication; + + @Inject() + taskInfoService: TaskInfoService; + + async data(job, done: any): Promise { + try { + const result = await this.taskInfoService.invokeService(job.data.service); + this.taskInfoService.record(job.data, 1, JSON.stringify(result)); + } catch (error) { + this.taskInfoService.record(job.data, 0, error.message); + } + if (!job.data.isOnce) { + this.taskInfoService.updateNextRunTime(job.data.id); + this.taskInfoService.updateStatus(job.data.id); + } + done(); + } +} diff --git a/src/modules/task/service/demo.ts b/src/modules/task/service/demo.ts new file mode 100644 index 0000000..9cc5bed --- /dev/null +++ b/src/modules/task/service/demo.ts @@ -0,0 +1,19 @@ +import { Logger, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { ILogger } from '@midwayjs/logger'; + +/** + * 描述 + */ +@Provide() +export class TaskDemoService extends BaseService { + @Logger() + logger: ILogger; + /** + * 描述 + */ + async test() { + this.logger.info('我被调用了'); + return '任务执行成功'; + } +} diff --git a/src/modules/task/service/info.ts b/src/modules/task/service/info.ts new file mode 100644 index 0000000..3d46ba2 --- /dev/null +++ b/src/modules/task/service/info.ts @@ -0,0 +1,348 @@ +import { + App, + Inject, + Logger, + Provide, + Scope, + ScopeEnum, +} from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Repository } from 'typeorm'; +import { TaskInfoEntity } from '../entity/info'; +import { TaskLogEntity } from '../entity/log'; +import { ILogger } from '@midwayjs/logger'; +import * as _ from 'lodash'; +import { Utils } from '../../../comm/utils'; +import { TaskInfoQueue } from '../queue/task'; +import { IMidwayApplication } from '@midwayjs/core'; + +/** + * 任务 + */ +@Provide() +@Scope(ScopeEnum.Request, { allowDowngrade: true }) +export class TaskInfoService extends BaseService { + @InjectEntityModel(TaskInfoEntity) + taskInfoEntity: Repository; + + @Logger() + logger: ILogger; + + @InjectEntityModel(TaskLogEntity) + taskLogEntity: Repository; + + @Inject() + taskInfoQueue: TaskInfoQueue; + + @App() + app: IMidwayApplication; + + @Inject() + utils: Utils; + + /** + * 停止任务 + * @param id + */ + async stop(id) { + const task = await this.taskInfoEntity.findOneBy({ id }); + if (task) { + const result = await this.taskInfoQueue.getRepeatableJobs(); + const job = _.find(result, { id: task.id + '' }); + if (job) { + await this.taskInfoQueue.removeRepeatableByKey(job.key); + } + task.status = 0; + await this.taskInfoEntity.update(task.id, task); + await this.updateNextRunTime(task.id); + } + } + + /** + * 移除任务 + * @param taskId + */ + async remove(taskId) { + const result = await this.taskInfoQueue.getRepeatableJobs(); + const job = _.find(result, { id: taskId + '' }); + await this.taskInfoQueue.removeRepeatableByKey(job.key); + } + + /** + * 开始任务 + * @param id + * @param type + */ + async start(id, type?) { + const task = await this.taskInfoEntity.findOneBy({ id }); + task.status = 1; + if (type || type == 0) { + task.type = type; + } + await this.addOrUpdate(task); + } + + /** + * 手动执行一次 + * @param id + */ + async once(id) { + const task = await this.taskInfoEntity.findOneBy({ id }); + if (task) { + await this.taskInfoQueue.add( + { + ...task, + isOnce: true, + }, + { + jobId: task.id.toString(), + removeOnComplete: true, + removeOnFail: true, + } + ); + } + } + + /** + * 检查任务是否存在 + * @param jobId + */ + async exist(jobId) { + const result = await this.taskInfoQueue.getRepeatableJobs(); + const ids = result.map(e => { + return e.id; + }); + return ids.includes(jobId.toString()); + } + + /** + * 新增或修改 + * @param params + */ + async addOrUpdate(params) { + delete params.repeatCount; + let repeatConf; + await this.getOrmManager().transaction(async transactionalEntityManager => { + if (params.taskType === 0) { + params.limit = null; + params.every = null; + } else { + params.cron = null; + } + await transactionalEntityManager.save(TaskInfoEntity, params); + + if (params.status === 1) { + const exist = await this.exist(params.id); + if (exist) { + await this.remove(params.id); + } + const { every, limit, startDate, endDate, cron } = params; + const repeat = { + every, + limit, + jobId: params.id, + startDate, + endDate, + cron, + }; + await this.utils.removeEmptyP(repeat); + const result = await this.taskInfoQueue.add(params, { + jobId: params.id, + removeOnComplete: true, + removeOnFail: true, + repeat, + }); + if (!result) { + throw new Error('任务添加失败,请检查任务配置'); + } + // await transactionalEntityManager.update(TaskInfoEntity, params.id, { + // jobId: params.id, + // type: params.type, + // }); + repeatConf = result.opts; + } + }); + if (params.status === 1) { + this.utils.sleep(1000); + await this.updateNextRunTime(params.id); + await this.nativeQuery( + 'update task_info a set a.repeatConf = ? where a.id = ?', + [JSON.stringify(repeatConf.repeat), params.id] + ); + } + } + + /** + * 删除 + * @param ids + */ + async delete(ids) { + let idArr; + if (ids instanceof Array) { + idArr = ids; + } else { + idArr = ids.split(','); + } + for (const id of idArr) { + const task = await this.taskInfoEntity.findOneBy({ id }); + const exist = await this.exist(task.id); + if (exist) { + this.stop(task.id); + } + await this.taskInfoEntity.delete({ id }); + await this.taskLogEntity.delete({ taskId: id }); + } + } + + /** + * 任务日志 + * @param query + */ + async log(query) { + const { id, status } = query; + return await this.sqlRenderPage( + ` + SELECT + a.*, + b.name AS taskName + FROM + task_log a + JOIN task_info b ON a.taskId = b.id + where 1=1 + ${this.setSql(id, 'and a.taskId = ?', [id])} + ${this.setSql(status, 'and a.status = ?', [status])} + `, + query + ); + } + + /** + * 保存任务记录,成功任务每个任务保留最新20条日志,失败日志不会删除 + * @param task + * @param status + * @param detail + */ + async record(task, status, detail?) { + await this.taskLogEntity.save({ + taskId: task.id, + status, + detail: detail || '', + }); + await this.nativeQuery( + `DELETE a + FROM + task_log a, + ( SELECT id FROM task_log where taskId = ? AND status = 1 ORDER BY id DESC LIMIT ?, 1 ) b + WHERE + a.taskId = ? AND + a.status = 1 AND + a.id < b.id`, + [task.id, 19, task.id] + ); // 日志保留最新的20条 + } + + /** + * 初始化任务 + */ + async initTask() { + try { + await this.utils.sleep(3000); + this.logger.info('init task....'); + const runningTasks = await this.taskInfoEntity.findBy({ status: 1 }); + if (!_.isEmpty(runningTasks)) { + for (const task of runningTasks) { + const job = await this.exist(task.id); // 任务已存在就不添加 + if (!job) { + this.logger.info(`init task ${task.name}`); + await this.addOrUpdate(task); + } + } + } + } catch (e) {} + } + + /** + * 任务ID + * @param jobId + */ + async getNextRunTime(jobId) { + let nextRunTime; + const result = await this.taskInfoQueue.getRepeatableJobs(); + const task = _.find(result, { id: jobId + '' }); + if (task) { + nextRunTime = new Date(task.next); + } + return nextRunTime; + } + + /** + * 更新下次执行时间 + * @param jobId + */ + async updateNextRunTime(jobId) { + await this.taskInfoEntity.update(jobId, { + nextRunTime: await this.getNextRunTime(jobId), + }); + } + + /** + * 详情 + * @param id + * @returns + */ + async info(id: any): Promise { + const info = await this.taskInfoEntity.findOneBy({ id }); + return { + ...info, + repeatCount: info.limit, + }; + } + + /** + * 刷新任务状态 + */ + async updateStatus(jobId) { + const result = await this.taskInfoQueue.getRepeatableJobs(); + const job = _.find(result, { id: jobId + '' }); + if (!job) { + return; + } + const task = await this.taskInfoEntity.findOneBy({ id: job.id }); + const nextTime = await this.getNextRunTime(task.id); + if (task) { + // if (task.nextRunTime.getTime() == nextTime.getTime()) { + // task.status = 0; + // task.nextRunTime = nextTime; + // this.taskInfoQueue.removeRepeatableByKey(job.key); + // } else { + task.nextRunTime = nextTime; + // } + await this.taskInfoEntity.update(task.id, task); + } + } + + /** + * 调用service + * @param serviceStr + */ + async invokeService(serviceStr) { + if (serviceStr) { + const arr = serviceStr.split('.'); + const service = await this.app + .getApplicationContext() + .getAsync(_.lowerFirst(arr[0])); + for (const child of arr) { + if (child.includes('(')) { + const lastArr = child.split('('); + const param = lastArr[1].replace(')', ''); + if (!param) { + return service[lastArr[0]](); + } else { + return service[lastArr[0]](JSON.parse(param)); + } + } + } + } + } +} diff --git a/src/modules/user/config.ts b/src/modules/user/config.ts new file mode 100644 index 0000000..ba4fd91 --- /dev/null +++ b/src/modules/user/config.ts @@ -0,0 +1,47 @@ +import { ModuleConfig } from '@cool-midway/core'; +import { UserMiddleware } from './middleware/app'; + +/** + * 模块配置 + */ +export default () => { + return { + // 模块名称 + name: '用户模块', + // 模块描述 + description: 'APP、小程序、公众号等用户', + // 中间件,只对本模块有效 + middlewares: [], + // 中间件,全局有效 + globalMiddlewares: [UserMiddleware], + // 模块加载顺序,默认为0,值越大越优先加载 + order: 0, + // 短信 + sms: { + // 验证码有效期,单位秒 + timeout: 60 * 3, + }, + // 微信配置 + wx: { + // 小程序 + mini: { + appid: '', + secret: '', + }, + // 公众号 + mp: { + appid: '', + secret: '', + }, + }, + // jwt + jwt: { + // token 过期时间,单位秒 + expire: 60 * 60 * 24, + // 刷新token 过期时间,单位秒 + refreshExpire: 60 * 60 * 24 * 30, + // jwt 秘钥 + secret: 'f7ad5480e28a11eeb7e2214c12a8b138', + }, + } as ModuleConfig; +}; diff --git a/src/modules/user/controller/admin/address.ts b/src/modules/user/controller/admin/address.ts new file mode 100644 index 0000000..fa18ade --- /dev/null +++ b/src/modules/user/controller/admin/address.ts @@ -0,0 +1,13 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { UserAddressEntity } from '../../entity/address'; +import { UserAddressService } from '../../service/address'; + +/** + * 用户-地址 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: UserAddressEntity, + service: UserAddressService, +}) +export class AdminUserAddressesController extends BaseController {} diff --git a/src/modules/user/controller/admin/info.ts b/src/modules/user/controller/admin/info.ts new file mode 100644 index 0000000..12f1480 --- /dev/null +++ b/src/modules/user/controller/admin/info.ts @@ -0,0 +1,15 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { UserInfoEntity } from '../../entity/info'; + +/** + * 用户信息 + */ +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: UserInfoEntity, + pageQueryOp: { + fieldEq: ['status', 'gender', 'loginType'], + keyWordLikeFields: ['nickName', 'phone'], + }, +}) +export class AdminUserInfoController extends BaseController {} diff --git a/src/modules/user/controller/app/address.ts b/src/modules/user/controller/app/address.ts new file mode 100644 index 0000000..a10a7fc --- /dev/null +++ b/src/modules/user/controller/app/address.ts @@ -0,0 +1,39 @@ +import { Get, Inject, Provide } from '@midwayjs/decorator'; +import { CoolController, BaseController } from '@cool-midway/core'; +import { UserAddressEntity } from '../../entity/address'; +import { UserAddressService } from '../../service/address'; + +/** + * 地址 + */ +@Provide() +@CoolController({ + api: ['add', 'delete', 'update', 'info', 'list', 'page'], + entity: UserAddressEntity, + service: UserAddressService, + insertParam: ctx => { + return { + userId: ctx.user.id, + }; + }, + pageQueryOp: { + where: async ctx => { + return [['userId =:userId', { userId: ctx.user.id }]]; + }, + addOrderBy: { + isDefault: 'DESC', + }, + }, +}) +export class AppUserAddressController extends BaseController { + @Inject() + userAddressService: UserAddressService; + + @Inject() + ctx; + + @Get('/default', { summary: '默认地址' }) + async default() { + return this.ok(await this.userAddressService.default(this.ctx.user.id)); + } +} diff --git a/src/modules/user/controller/app/comm.ts b/src/modules/user/controller/app/comm.ts new file mode 100644 index 0000000..be06d6c --- /dev/null +++ b/src/modules/user/controller/app/comm.ts @@ -0,0 +1,25 @@ +import { + CoolController, + BaseController, + CoolUrlTag, + TagTypes, + CoolTag, +} from '@cool-midway/core'; +import { Body, Inject, Post } from '@midwayjs/core'; +import { UserWxService } from '../../service/wx'; + +/** + * 通用 + */ +@CoolUrlTag() +@CoolController() +export class UserCommController extends BaseController { + @Inject() + userWxService: UserWxService; + + @CoolTag(TagTypes.IGNORE_TOKEN) + @Post('/wxMpConfig', { summary: '获取微信公众号配置' }) + public async getWxMpConfig(@Body('url') url: string) { + return this.ok(await this.userWxService.getWxMpConfig(url)); + } +} diff --git a/src/modules/user/controller/app/info.ts b/src/modules/user/controller/app/info.ts new file mode 100644 index 0000000..b1a94ab --- /dev/null +++ b/src/modules/user/controller/app/info.ts @@ -0,0 +1,65 @@ +import { CoolController, BaseController } from '@cool-midway/core'; +import { Body, Get, Inject, Post } from '@midwayjs/core'; +import { UserInfoService } from '../../service/info'; +import { UserInfoEntity } from '../../entity/info'; + +/** + * 用户信息 + */ +@CoolController({ + api: [], + entity: UserInfoEntity, +}) +export class AppUserInfoController extends BaseController { + @Inject() + ctx; + + @Inject() + userInfoService: UserInfoService; + + @Get('/person', { summary: '获取用户信息' }) + async person() { + return this.ok(await this.userInfoService.person(this.ctx.user.id)); + } + + @Post('/updatePerson', { summary: '更新用户信息' }) + async updatePerson(@Body() body) { + return this.ok( + await this.userInfoService.updatePerson(this.ctx.user.id, body) + ); + } + + @Post('/updatePassword', { summary: '更新用户密码' }) + async updatePassword( + @Body('password') password: string, + @Body('code') code: string + ) { + await this.userInfoService.updatePassword(this.ctx.user.id, password, code); + return this.ok(); + } + + @Post('/logoff', { summary: '注销' }) + async logoff() { + await this.userInfoService.logoff(this.ctx.user.id); + return this.ok(); + } + + @Post('/bindPhone', { summary: '绑定手机号' }) + async bindPhone(@Body('phone') phone: string, @Body('code') code: string) { + await this.userInfoService.bindPhone(this.ctx.user.id, phone, code); + return this.ok(); + } + + @Post('/miniPhone', { summary: '绑定小程序手机号' }) + async miniPhone(@Body() body) { + const { code, encryptedData, iv } = body; + return this.ok( + await this.userInfoService.miniPhone( + this.ctx.user.id, + code, + encryptedData, + iv + ) + ); + } +} diff --git a/src/modules/user/controller/app/login.ts b/src/modules/user/controller/app/login.ts new file mode 100644 index 0000000..efa1e4b --- /dev/null +++ b/src/modules/user/controller/app/login.ts @@ -0,0 +1,86 @@ +import { + CoolController, + BaseController, + CoolUrlTag, + TagTypes, + CoolTag, +} from '@cool-midway/core'; +import { Body, Get, Inject, Post, Query } from '@midwayjs/core'; +import { UserLoginService } from '../../service/login'; +import { BaseSysLoginService } from '../../../base/service/sys/login'; + +/** + * 登录 + */ +@CoolUrlTag() +@CoolController() +export class AppUserLoginController extends BaseController { + @Inject() + userLoginService: UserLoginService; + + @Inject() + baseSysLoginService: BaseSysLoginService; + + @CoolTag(TagTypes.IGNORE_TOKEN) + @Post('/mini', { summary: '小程序登录' }) + async mini(@Body() body) { + const { code, encryptedData, iv } = body; + return this.ok(await this.userLoginService.mini(code, encryptedData, iv)); + } + + @CoolTag(TagTypes.IGNORE_TOKEN) + @Post('/mp', { summary: '公众号登录' }) + async mp(@Body('code') code: string) { + return this.ok(await this.userLoginService.mp(code)); + } + + @CoolTag(TagTypes.IGNORE_TOKEN) + @Post('/wxApp', { summary: '微信APP授权登录' }) + async app(@Body('code') code: string) { + return this.ok(await this.userLoginService.wxApp(code)); + } + + @CoolTag(TagTypes.IGNORE_TOKEN) + @Post('/phone', { summary: '手机号登录' }) + async phone(@Body('phone') phone: string, @Body('smsCode') smsCode: string) { + return this.ok(await this.userLoginService.phone(phone, smsCode)); + } + + @CoolTag(TagTypes.IGNORE_TOKEN) + @Get('/captcha', { summary: '图片验证码' }) + async captcha( + @Query('type') type: string, + @Query('width') width: number, + @Query('height') height: number, + @Query('color') color: string + ) { + return this.ok( + await this.baseSysLoginService.captcha(type, width, height, color) + ); + } + + @CoolTag(TagTypes.IGNORE_TOKEN) + @Post('/smsCode', { summary: '验证码' }) + async smsCode( + @Body('phone') phone: string, + @Body('captchaId') captchaId: string, + @Body('code') code: string + ) { + return this.ok(await this.userLoginService.smsCode(phone, captchaId, code)); + } + + @CoolTag(TagTypes.IGNORE_TOKEN) + @Post('/refreshToken', { summary: '刷新token' }) + public async refreshToken(@Body('refreshToken') refreshToken) { + return this.ok(await this.userLoginService.refreshToken(refreshToken)); + } + + @CoolTag(TagTypes.IGNORE_TOKEN) + @Post('/password', { summary: '密码登录' }) + async password( + @Body('phone') phone: string, + @Body('password') password: string + ) { + return this.ok(await this.userLoginService.password(phone, password)); + } +} diff --git a/src/modules/user/entity/address.ts b/src/modules/user/entity/address.ts new file mode 100644 index 0000000..6b93391 --- /dev/null +++ b/src/modules/user/entity/address.ts @@ -0,0 +1,34 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Entity, Column, Index } from 'typeorm'; + +/** + * 用户模块-收货地址 + */ +@Entity('user_address') +export class UserAddressEntity extends BaseEntity { + @Index() + @Column({ comment: '用户ID' }) + userId: number; + + @Column({ comment: '联系人' }) + contact: string; + + @Index() + @Column({ comment: '手机号', length: 11 }) + phone: string; + + @Column({ comment: '省' }) + province: string; + + @Column({ comment: '市' }) + city: string; + + @Column({ comment: '区' }) + district: string; + + @Column({ comment: '地址' }) + address: string; + + @Column({ comment: '是否默认', default: false }) + isDefault: boolean; +} diff --git a/src/modules/user/entity/info.ts b/src/modules/user/entity/info.ts new file mode 100644 index 0000000..d0bbac3 --- /dev/null +++ b/src/modules/user/entity/info.ts @@ -0,0 +1,34 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 用户信息 + */ +@Entity('user_info') +export class UserInfoEntity extends BaseEntity { + @Index({ unique: true }) + @Column({ comment: '登录唯一ID', nullable: true }) + unionid: string; + + @Column({ comment: '头像', nullable: true }) + avatarUrl: string; + + @Column({ comment: '昵称', nullable: true }) + nickName: string; + + @Index({ unique: true }) + @Column({ comment: '手机号', nullable: true }) + phone: string; + + @Column({ comment: '性别 0-未知 1-男 2-女', default: 0 }) + gender: number; + + @Column({ comment: '状态 0-禁用 1-正常 2-已注销', default: 1 }) + status: number; + + @Column({ comment: '登录方式 0-小程序 1-公众号 2-H5', default: 0 }) + loginType: number; + + @Column({ comment: '密码', nullable: true }) + password: string; +} diff --git a/src/modules/user/entity/wx.ts b/src/modules/user/entity/wx.ts new file mode 100644 index 0000000..ca0ae59 --- /dev/null +++ b/src/modules/user/entity/wx.ts @@ -0,0 +1,40 @@ +import { BaseEntity } from '@cool-midway/core'; +import { Column, Entity, Index } from 'typeorm'; + +/** + * 微信用户 + */ +@Entity('user_wx') +export class UserWxEntity extends BaseEntity { + @Index() + @Column({ comment: '微信unionid', nullable: true }) + unionid: string; + + @Index() + @Column({ comment: '微信openid' }) + openid: string; + + @Column({ comment: '头像', nullable: true }) + avatarUrl: string; + + @Column({ comment: '昵称', nullable: true }) + nickName: string; + + @Column({ comment: '性别 0-未知 1-男 2-女', default: 0 }) + gender: number; + + @Column({ comment: '语言', nullable: true }) + language: string; + + @Column({ comment: '城市', nullable: true }) + city: string; + + @Column({ comment: '省份', nullable: true }) + province: string; + + @Column({ comment: '国家', nullable: true }) + country: string; + + @Column({ comment: '类型 0-小程序 1-公众号 2-H5 3-APP', default: 0 }) + type: number; +} diff --git a/src/modules/user/event/app.ts b/src/modules/user/event/app.ts new file mode 100644 index 0000000..f5ba9e1 --- /dev/null +++ b/src/modules/user/event/app.ts @@ -0,0 +1,53 @@ +import { CoolEvent, Event } from '@cool-midway/core'; +import { App, Config, ILogger, Logger } from '@midwayjs/core'; +import { IMidwayKoaApplication } from '@midwayjs/koa'; +import * as fs from 'fs'; +import * as path from 'path'; +import { v1 as uuid } from 'uuid'; + +/** + * 修改jwt.secret + */ +@CoolEvent() +export class UserAppEvent { + @Logger() + coreLogger: ILogger; + + @Config('module') + config; + + @App() + app: IMidwayKoaApplication; + + @Event('onMenuInit') + async onMenuInit() { + this.checkConfig(); + } + + /** + * 检查配置 + */ + async checkConfig() { + if (this.config.user.jwt.secret == 'cool-app-xxxxxx') { + this.coreLogger.warn( + '\x1B[36m 检测到模块[user] jwt.secret 配置是默认值,请不要关闭!即将自动修改... \x1B[0m' + ); + setTimeout(() => { + const filePath = path.join( + this.app.getBaseDir(), + 'modules', + 'user', + 'config.ts' + ); + // 替换文件内容 + let fileData = fs.readFileSync(filePath, 'utf8'); + const secret = uuid().replace(/-/g, ''); + this.config.user.jwt.secret = secret; + fs.writeFileSync(filePath, fileData.replace('cool-app-xxxxxx', secret)); + this.coreLogger.info( + '\x1B[36m [cool:module:user] midwayjs cool module user auto modify jwt.secret\x1B[0m' + ); + }, 6000); + } + } +} diff --git a/src/modules/user/middleware/app.ts b/src/modules/user/middleware/app.ts new file mode 100644 index 0000000..7fbba3a --- /dev/null +++ b/src/modules/user/middleware/app.ts @@ -0,0 +1,96 @@ +import { ALL, Config, Middleware } from '@midwayjs/decorator'; +import { NextFunction, Context } from '@midwayjs/koa'; +import { IMiddleware, Init, Inject } from '@midwayjs/core'; +import * as jwt from 'jsonwebtoken'; +import * as _ from 'lodash'; +import { CoolUrlTagData, RESCODE, TagTypes } from '@cool-midway/core'; + +/** + * 用户 + */ +@Middleware() +export class UserMiddleware implements IMiddleware { + @Config(ALL) + coolConfig; + + @Inject() + coolUrlTagData: CoolUrlTagData; + + @Config('module.user.jwt') + jwtConfig; + + ignoreUrls: string[] = []; + + @Config('koa.globalPrefix') + prefix; + + @Init() + async init() { + this.ignoreUrls = this.coolUrlTagData.byKey(TagTypes.IGNORE_TOKEN, 'app'); + } + + resolve() { + return async (ctx: Context, next: NextFunction) => { + let { url } = ctx; + url = url.replace(this.prefix, '').split('?')[0]; + if (_.startsWith(url, '/app/')) { + const token = ctx.get('Authorization'); + try { + ctx.user = jwt.verify(token, this.jwtConfig.secret); + if (ctx.user.isRefresh) { + ctx.status = 401; + ctx.body = { + code: RESCODE.COMMFAIL, + message: '登录失效~', + }; + return; + } + } catch (error) {} + // 使用matchUrl方法来检查URL是否应该被忽略 + const isIgnored = this.ignoreUrls.some(pattern => + this.matchUrl(pattern, url) + ); + if (isIgnored) { + await next(); + return; + } else { + if (!ctx.user) { + ctx.status = 401; + ctx.body = { + code: RESCODE.COMMFAIL, + message: '登录失效~', + }; + return; + } + } + } + await next(); + }; + } + + // 匹配URL的方法 + matchUrl(pattern, url) { + const patternSegments = pattern.split('/').filter(Boolean); + const urlSegments = url.split('/').filter(Boolean); + + // 如果段的数量不同,则无法匹配 + if (patternSegments.length !== urlSegments.length) { + return false; + } + + // 逐段进行匹配 + for (let i = 0; i < patternSegments.length; i++) { + if (patternSegments[i].startsWith(':')) { + // 如果模式段以':'开始,我们认为它是一个参数,可以匹配任何内容 + continue; + } + // 如果两个段不相同,则不匹配 + if (patternSegments[i] !== urlSegments[i]) { + return false; + } + } + + // 所有段都匹配 + return true; + } +} diff --git a/src/modules/user/service/address.ts b/src/modules/user/service/address.ts new file mode 100644 index 0000000..6a3a3a7 --- /dev/null +++ b/src/modules/user/service/address.ts @@ -0,0 +1,63 @@ +import { Init, Inject, Provide } from '@midwayjs/decorator'; +import { BaseService } from '@cool-midway/core'; +import { Equal, Repository } from 'typeorm'; +import { UserAddressEntity } from '../entity/address'; +import { InjectEntityModel } from '@midwayjs/typeorm'; + +/** + * 地址 + */ +@Provide() +export class UserAddressService extends BaseService { + @InjectEntityModel(UserAddressEntity) + userAddressEntity: Repository; + + @Inject() + ctx; + + @Init() + async init() { + await super.init(); + this.setEntity(this.userAddressEntity); + } + + /** + * 列表信息 + */ + async list() { + return this.userAddressEntity + .createQueryBuilder() + .where('userId = :userId ', { userId: this.ctx.user.id }) + .addOrderBy('isDefault', 'DESC') + .getMany(); + } + + /** + * 修改之后 + * @param data + * @param type + */ + async modifyAfter(data: any, type: 'add' | 'update' | 'delete') { + if (type == 'add' || type == 'update') { + if (data.isDefault) { + await this.userAddressEntity + .createQueryBuilder() + .update() + .set({ isDefault: false }) + .where('userId = :userId ', { userId: this.ctx.user.id }) + .andWhere('id != :id', { id: data.id }) + .execute(); + } + } + } + + /** + * 默认地址 + */ + async default(userId) { + return await this.userAddressEntity.findOneBy({ + userId: Equal(userId), + isDefault: true, + }); + } +} diff --git a/src/modules/user/service/info.ts b/src/modules/user/service/info.ts new file mode 100644 index 0000000..b2ba41d --- /dev/null +++ b/src/modules/user/service/info.ts @@ -0,0 +1,127 @@ +import { Inject, Provide } from '@midwayjs/decorator'; +import { BaseService, CoolCommException } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Equal, Repository } from 'typeorm'; +import { UserInfoEntity } from '../entity/info'; +import { v1 as uuid } from 'uuid'; +import { UserSmsService } from './sms'; +import * as md5 from 'md5'; +import { PluginService } from '../../plugin/service/info'; +import { UserWxService } from './wx'; + +/** + * 用户信息 + */ +@Provide() +export class UserInfoService extends BaseService { + @InjectEntityModel(UserInfoEntity) + userInfoEntity: Repository; + + @Inject() + pluginService: PluginService; + + @Inject() + userSmsService: UserSmsService; + + @Inject() + userWxService: UserWxService; + + /** + * 绑定小程序手机号 + * @param userId + * @param code + * @param encryptedData + * @param iv + */ + async miniPhone(userId: number, code: any, encryptedData: any, iv: any) { + const phone = await this.userWxService.miniPhone(code, encryptedData, iv); + await this.userInfoEntity.update({ id: Equal(userId) }, { phone }); + return phone; + } + + /** + * 获取用户信息 + * @param id + * @returns + */ + async person(id) { + const info = await this.userInfoEntity.findOneBy({ id: Equal(id) }); + // 手机号脱敏 + if (info && info.phone) { + info.phone = info.phone.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'); + } + return info; + } + + /** + * 注销 + * @param userId + */ + async logoff(userId: number) { + await this.userInfoEntity.update( + { id: userId }, + { + status: 2, + phone: null, + unionid: null, + nickName: `已注销-00${userId}`, + avatarUrl: null, + } + ); + } + + /** + * 更新用户信息 + * @param id + * @param param + * @returns + */ + async updatePerson(id, param) { + const info = await this.person(id); + if (!info) throw new CoolCommException('用户不存在'); + try { + // 修改了头像要重新处理 + if (param.avatarUrl && info.avatarUrl != param.avatarUrl) { + const file = await this.pluginService.getInstance('upload'); + param.avatarUrl = await file.downAndUpload( + param.avatarUrl, + uuid() + '.png' + ); + } + } catch (err) {} + try { + return await this.userInfoEntity.update({ id }, param); + } catch (err) { + throw new CoolCommException('更新失败,参数错误或者手机号已存在'); + } + } + + /** + * 更新密码 + * @param userId + * @param password + * @param 验证码 + */ + async updatePassword(userId, password, code) { + const user = await this.userInfoEntity.findOneBy({ id: userId }); + const check = await this.userSmsService.checkCode(user.phone, code); + if (!check) { + throw new CoolCommException('验证码错误'); + } + await this.userInfoEntity.update(user.id, { password: md5(password) }); + } + + /** + * 绑定手机号 + * @param userId + * @param phone + * @param code + */ + async bindPhone(userId, phone, code) { + const check = await this.userSmsService.checkCode(phone, code); + if (!check) { + throw new CoolCommException('验证码错误'); + } + await this.userInfoEntity.update({ id: userId }, { phone }); + } +} diff --git a/src/modules/user/service/login.ts b/src/modules/user/service/login.ts new file mode 100644 index 0000000..5170118 --- /dev/null +++ b/src/modules/user/service/login.ts @@ -0,0 +1,268 @@ +import { Config, Inject, Provide } from '@midwayjs/decorator'; +import { BaseService, CoolCommException } from '@cool-midway/core'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Equal, Repository } from 'typeorm'; +import { UserInfoEntity } from '../entity/info'; +import { UserWxService } from './wx'; +import * as jwt from 'jsonwebtoken'; +import { UserWxEntity } from '../entity/wx'; +import { BaseSysLoginService } from '../../base/service/sys/login'; +import { UserSmsService } from './sms'; +import { v1 as uuid } from 'uuid'; +import * as md5 from 'md5'; +import { PluginService } from '../../plugin/service/info'; + +/** + * 登录 + */ +@Provide() +export class UserLoginService extends BaseService { + @InjectEntityModel(UserInfoEntity) + userInfoEntity: Repository; + + @InjectEntityModel(UserWxEntity) + userWxEntity: Repository; + + @Inject() + userWxService: UserWxService; + + @Config('module.user.jwt') + jwtConfig; + + @Inject() + baseSysLoginService: BaseSysLoginService; + + @Inject() + pluginService: PluginService; + + @Inject() + userSmsService: UserSmsService; + + /** + * 发送手机验证码 + * @param phone + * @param captchaId + * @param code + */ + async smsCode(phone, captchaId, code) { + // 1、检查图片验证码 2、发送短信验证码 + const check = await this.baseSysLoginService.captchaCheck(captchaId, code); + if (!check) { + throw new CoolCommException('图片验证码错误'); + } + await this.userSmsService.sendSms(phone); + } + + /** + * 手机登录 + * @param phone + * @param smsCode + */ + async phone(phone, smsCode) { + // 1、检查短信验证码 2、登录 + const check = await this.userSmsService.checkCode(phone, smsCode); + if (check) { + let user: any = await this.userInfoEntity.findOneBy({ + phone: Equal(phone), + }); + if (!user) { + user = { + phone, + unionid: phone, + loginType: 2, + nickName: phone.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2'), + }; + await this.userInfoEntity.insert(user); + } + return this.token({ id: user.id }); + } else { + throw new CoolCommException('验证码错误'); + } + } + + /** + * 公众号登录 + * @param code + */ + async mp(code: string) { + let wxUserInfo = await this.userWxService.mpUserInfo(code); + if (wxUserInfo) { + delete wxUserInfo.privilege; + wxUserInfo = await this.saveWxInfo( + { + openid: wxUserInfo.openid, + unionid: wxUserInfo.unionid, + avatarUrl: wxUserInfo.headimgurl, + nickName: wxUserInfo.nickname, + gender: wxUserInfo.sex, + city: wxUserInfo.city, + province: wxUserInfo.province, + country: wxUserInfo.country, + }, + 1 + ); + return this.wxLoginToken(wxUserInfo); + } else { + throw new Error('微信登录失败'); + } + } + + /** + * 微信APP授权登录 + * @param code + */ + async wxApp(code: string) { + let wxUserInfo = await this.userWxService.appUserInfo(code); + if (wxUserInfo) { + delete wxUserInfo.privilege; + wxUserInfo = await this.saveWxInfo( + { + openid: wxUserInfo.openid, + unionid: wxUserInfo.unionid, + avatarUrl: wxUserInfo.headimgurl, + nickName: wxUserInfo.nickname, + gender: wxUserInfo.sex, + city: wxUserInfo.city, + province: wxUserInfo.province, + country: wxUserInfo.country, + }, + 1 + ); + return this.wxLoginToken(wxUserInfo); + } else { + throw new Error('微信登录失败'); + } + } + + /** + * 保存微信信息 + * @param wxUserInfo + * @param type + * @returns + */ + async saveWxInfo(wxUserInfo, type) { + const find: any = { openid: wxUserInfo.openid }; + let wxInfo: any = await this.userWxEntity.findOneBy(find); + if (wxInfo) { + wxUserInfo.id = wxInfo.id; + } + await this.userWxEntity.save({ + ...wxUserInfo, + type, + }); + return wxUserInfo; + } + + /** + * 小程序登录 + * @param code + * @param encryptedData + * @param iv + */ + async mini(code, encryptedData, iv) { + let wxUserInfo = await this.userWxService.miniUserInfo( + code, + encryptedData, + iv + ); + if (wxUserInfo) { + // 保存 + wxUserInfo = await this.saveWxInfo(wxUserInfo, 0); + return await this.wxLoginToken(wxUserInfo); + } + } + + /** + * 微信登录 获得token + * @param wxUserInfo 微信用户信息 + * @returns + */ + async wxLoginToken(wxUserInfo) { + const unionid = wxUserInfo.unionid ? wxUserInfo.unionid : wxUserInfo.openid; + let userInfo: any = await this.userInfoEntity.findOneBy({ unionid }); + if (!userInfo) { + const file = await this.pluginService.getInstance('upload'); + const avatarUrl = await file.downAndUpload( + wxUserInfo.avatarUrl, + uuid() + '.png' + ); + userInfo = { + unionid, + nickName: wxUserInfo.nickName, + avatarUrl, + gender: wxUserInfo.gender, + }; + await this.userInfoEntity.insert(userInfo); + } + return this.token({ id: userInfo.id }); + } + + /** + * 刷新token + * @param refreshToken + */ + async refreshToken(refreshToken) { + try { + const info = jwt.verify(refreshToken, this.jwtConfig.secret); + if (!info['isRefresh']) { + throw new CoolCommException('token类型非refreshToken'); + } + const userInfo = await this.userInfoEntity.findOneBy({ + id: info['id'], + }); + return this.token({ id: userInfo.id }); + } catch (e) { + throw new CoolCommException( + '刷新token失败,请检查refreshToken是否正确或过期' + ); + } + } + + /** + * 密码登录 + * @param phone + * @param password + */ + async password(phone, password) { + const user = await this.userInfoEntity.findOneBy({ phone }); + + if (user && user.password == md5(password)) { + return this.token({ + id: user.id, + }); + } else { + throw new CoolCommException('账号或密码错误'); + } + } + + /** + * 获得token + * @param info + * @returns + */ + async token(info) { + const { expire, refreshExpire } = this.jwtConfig; + return { + expire, + token: await this.generateToken(info), + refreshExpire, + refreshToken: await this.generateToken(info, true), + }; + } + + /** + * 生成token + * @param tokenInfo 信息 + * @param roleIds 角色集合 + */ + async generateToken(info, isRefresh = false) { + const { expire, refreshExpire, secret } = this.jwtConfig; + const tokenInfo = { + isRefresh, + ...info, + }; + return jwt.sign(tokenInfo, secret, { + expiresIn: isRefresh ? refreshExpire : expire, + }); + } +} diff --git a/src/modules/user/service/sms.ts b/src/modules/user/service/sms.ts new file mode 100644 index 0000000..ca774bf --- /dev/null +++ b/src/modules/user/service/sms.ts @@ -0,0 +1,82 @@ +import { + Provide, + Config, + Inject, + Init, + InjectClient, +} from '@midwayjs/decorator'; +import { BaseService, CoolCommException } from '@cool-midway/core'; +import * as _ from 'lodash'; +import { CachingFactory, MidwayCache } from '@midwayjs/cache-manager'; +import { PluginService } from '../../plugin/service/info'; + +/** + * 描述 + */ +@Provide() +export class UserSmsService extends BaseService { + // 获得模块的配置信息 + @Config('module.user.sms') + config; + + @InjectClient(CachingFactory, 'default') + midwayCache: MidwayCache; + + @Inject() + pluginService: PluginService; + + plugin; + + @Init() + async init() { + for (const key of ['sms-tx', 'sms-ali']) { + try { + this.plugin = await this.pluginService.getInstance(key); + if (this.plugin) { + this.config.pluginKey = key; + break; + } + } catch (e) { + continue; + } + } + } + + /** + * 发送验证码 + * @param phone + */ + async sendSms(phone) { + // 随机四位验证码 + const code = _.random(1000, 9999); + const pluginKey = this.config.pluginKey; + if (!this.plugin) throw new CoolCommException('未配置短信插件'); + try { + if (pluginKey == 'sms-tx') { + await this.plugin.send([phone], [code]); + } + if (pluginKey == 'sms-ali') { + await this.plugin.send([phone], { + code, + }); + } + this.midwayCache.set(`sms:${phone}`, code, this.config.timeout * 1000); + } catch (error) { + throw new CoolCommException('发送过于频繁,请稍后再试'); + } + } + + /** + * 验证验证码 + * @param phone + * @param code + * @returns + */ + async checkCode(phone, code) { + const cacheCode = await this.midwayCache.get(`sms:${phone}`); + if (cacheCode == code) { + return true; + } + return false; + } +} diff --git a/src/modules/user/service/wx.ts b/src/modules/user/service/wx.ts new file mode 100644 index 0000000..528eabf --- /dev/null +++ b/src/modules/user/service/wx.ts @@ -0,0 +1,266 @@ +import { Config, Init, Inject, Provide } from '@midwayjs/decorator'; +import { BaseService, CoolCache, CoolCommException } from '@cool-midway/core'; +import axios from 'axios'; +import * as crypto from 'crypto'; +import { v1 as uuid } from 'uuid'; +import * as moment from 'moment'; +import { InjectEntityModel } from '@midwayjs/typeorm'; +import { Equal, Repository } from 'typeorm'; +import { UserInfoEntity } from '../entity/info'; +import { UserWxEntity } from '../entity/wx'; +import { PluginService } from '../../plugin/service/info'; + +/** + * 微信 + */ +@Provide() +export class UserWxService extends BaseService { + @Config('module.user') + config; + + @InjectEntityModel(UserInfoEntity) + userInfoEntity: Repository; + + @InjectEntityModel(UserWxEntity) + userWxEntity: Repository; + + @Inject() + pluginService: PluginService; + + /** + * 获得小程序实例 + * @returns + */ + async getMiniApp() { + const wxPlugin = await this.pluginService.getInstance('wx'); + return wxPlugin.MiniApp(); + } + + /** + * 获得公众号实例 + * @returns + */ + async getOfficialAccount() { + const wxPlugin = await this.pluginService.getInstance('wx'); + return wxPlugin.OfficialAccount(); + } + + /** + * 获得App实例 + * @returns + */ + async getOpenPlatform() { + const wxPlugin = await this.pluginService.getInstance('wx'); + return wxPlugin.OpenPlatform(); + } + + /** + * 获得用户的openId + * @param userId + * @param type 0-小程序 1-公众号 2-App + */ + async getOpenid(userId: number, type = 0) { + const user = await this.userInfoEntity.findOneBy({ + id: Equal(userId), + status: 1, + }); + if (!user) { + throw new CoolCommException('用户不存在或已被禁用'); + } + const wx = await this.userWxEntity + .createQueryBuilder('a') + .where('a.type = :type', { type }) + .andWhere('(a.unionid = :unionid or a.openid =:openid )', { + unionid: user.unionid, + openid: user.unionid, + }) + .getOne(); + return wx ? wx.openid : null; + } + + /** + * 获得微信配置 + * @param appId + * @param appSecret + * @param url 当前网页的URL,不包含#及其后面部分(必须是调用JS接口页面的完整URL) + */ + public async getWxMpConfig(url: string) { + const token = await this.getWxToken(); + const ticket = await axios.get( + 'https://api.weixin.qq.com/cgi-bin/ticket/getticket', + { + params: { + access_token: token.access_token, + type: 'jsapi', + }, + } + ); + + const account = (await this.getOfficialAccount()).getAccount(); + const appid = account.getAppId(); + // 返回结果集 + const result = { + timestamp: parseInt(moment().valueOf() / 1000 + ''), + nonceStr: uuid(), + appId: appid, //appid + signature: '', + }; + const signArr = []; + signArr.push('jsapi_ticket=' + ticket.data.ticket); + signArr.push('noncestr=' + result.nonceStr); + signArr.push('timestamp=' + result.timestamp); + signArr.push('url=' + decodeURI(url)); + // 敏感信息加密处理 + result.signature = crypto + .createHash('sha1') + .update(signArr.join('&')) + .digest('hex') + .toUpperCase(); + return result; + } + + /** + * 获得公众号用户信息 + * @param code + */ + async mpUserInfo(code) { + const token = await this.openOrMpToken(code, 'mp'); + return await this.openOrMpUserInfo(token); + } + + /** + * 获得app用户信息 + * @param code + */ + async appUserInfo(code) { + const token = await this.openOrMpToken(code, 'open'); + return await this.openOrMpUserInfo(token); + } + + /** + * 获得微信token 不用code + * @param appid + * @param secret + */ + public async getWxToken(type = 'mp') { + let app; + if (type == 'mp') { + app = await this.getOfficialAccount(); + } else { + app = await this.getOpenPlatform(); + } + return await app.getAccessToken().getToken(); + } + + /** + * 获得用户信息 + * @param token + */ + async openOrMpUserInfo(token) { + return await axios + .get('https://api.weixin.qq.com/sns/userinfo', { + params: { + access_token: token.access_token, + openid: token.openid, + lang: 'zh_CN', + }, + }) + .then(res => { + return res.data; + }); + } + + /** + * 获得token嗯 + * @param code + * @param type + */ + async openOrMpToken(code, type = 'mp') { + const account = + type == 'mp' + ? (await this.getOfficialAccount()).getAccount() + : (await this.getMiniApp()).getAccount(); + const result = await axios.get( + 'https://api.weixin.qq.com/sns/oauth2/access_token', + { + params: { + appid: account.getAppId(), + secret: account.getSecret(), + code, + grant_type: 'authorization_code', + }, + } + ); + return result.data; + } + + /** + * 获得小程序session + * @param code 微信code + * @param conf 配置 + */ + async miniSession(code) { + const app = await this.getMiniApp(); + const utils = app.getUtils(); + const result = await utils.codeToSession(code); + return result; + } + + /** + * 获得小程序用户信息 + * @param code + * @param encryptedData + * @param iv + */ + async miniUserInfo(code, encryptedData, iv) { + const session = await this.miniSession(code); + if (session.errcode) { + throw new CoolCommException('登录失败,请重试'); + } + const info: any = await this.miniDecryptData( + encryptedData, + iv, + session.session_key + ); + if (info) { + delete info['watermark']; + return { + ...info, + openid: session['openid'], + unionid: session['unionid'], + }; + } + return null; + } + + /** + * 获得小程序手机 + * @param code + * @param encryptedData + * @param iv + */ + async miniPhone(code, encryptedData, iv) { + const session = await this.miniSession(code); + if (session.errcode) { + throw new CoolCommException('获取手机号失败,请刷新重试'); + } + const result = await this.miniDecryptData( + encryptedData, + iv, + session.session_key + ); + return result.phoneNumber; + } + + /** + * 小程序信息解密 + * @param encryptedData + * @param iv + * @param sessionKey + */ + async miniDecryptData(encryptedData, iv, sessionKey) { + const app = await this.getMiniApp(); + const utils = app.getUtils(); + return await utils.decryptSession(sessionKey, iv, encryptedData); + } +} diff --git a/src/welcome.ts b/src/welcome.ts new file mode 100644 index 0000000..f7481ab --- /dev/null +++ b/src/welcome.ts @@ -0,0 +1,19 @@ +import { Controller, Get, Inject } from '@midwayjs/decorator'; +import { Context } from '@midwayjs/koa'; +import * as packageJson from '../package.json'; + +/** + * 欢迎界面 + */ +@Controller('/') +export class WelcomeController { + @Inject() + ctx: Context; + + @Get('/', { summary: '欢迎界面' }) + public async welcome() { + await this.ctx.render('welcome', { + text: `HELLO COOL-ADMIN v${packageJson.version} 一个项目用COOL就够了!!!`, + }); + } +} diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..7effc7e --- /dev/null +++ b/test/README.md @@ -0,0 +1,12 @@ +# 测试方式 + +考虑到cool-admin采用了自动化路由技术,它与官方集成的jest测试工具并不兼容。为确保测试环境与实际的开发环境保持一致,我们并不推荐使用jest进行测试。 + +# 自动化测试API工具 + +我们为您推荐以下的自动化API测试工具: + +- [Apifox](https://apifox.com/) +- [ApiPost](https://www.apipost.cn/) + +同时这些工具也方便写API接口文档,更加灵活有用 \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..eb4731b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "target": "es2018", + "module": "commonjs", + "moduleResolution": "node", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "inlineSourceMap":true, + "noImplicitThis": true, + "noUnusedLocals": false, + "stripInternal": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "pretty": true, + "declaration": true, + "noImplicitAny": false, + "typeRoots": [ "./typings", "./node_modules/@types"], + "outDir": "dist", + "rootDir": "src" + }, + "exclude": [ + "dist", + "node_modules", + "test" + ] +} diff --git a/view/swagger.html b/view/swagger.html new file mode 100644 index 0000000..8efe7e1 --- /dev/null +++ b/view/swagger.html @@ -0,0 +1,19 @@ + + + + + + Cool Admin Swagger UI + + + + + + + +
+ + + + + diff --git a/view/welcome.html b/view/welcome.html new file mode 100644 index 0000000..923ac48 --- /dev/null +++ b/view/welcome.html @@ -0,0 +1,28 @@ + + + + + + + + COOL-AMIND 一个很酷的后台权限管理系统 + + + + + + +
<%= text %>
+ + + + + + + +