From 3e4c3e52d05f1308a44c87cce858851d2e65a134 Mon Sep 17 00:00:00 2001 From: fw6 Date: Thu, 3 Jan 2019 20:35:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=80=E5=A7=8B=E6=B5=8B=E8=AF=95=EF=BF=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .autod.conf.js | 30 ++++ .eslintignore | 1 + .eslintrc | 6 + .gitignore | 12 ++ .travis.yml | 10 ++ .vscode/settings.json | 2 + README.md | 33 +++++ README.zh-CN.md | 98 ++++++++++++++ app/controller/home.js | 11 ++ app/controller/role.js | 75 ++++++++++ app/controller/upload.js | 218 ++++++++++++++++++++++++++++++ app/controller/user.js | 108 +++++++++++++++ app/controller/userAccess.js | 137 +++++++++++++++++++ app/extend/RESTfulHTTPSStatus.rec | 14 ++ app/extend/helper.js | 15 ++ app/middleware/error_handler.js | 27 ++++ app/model/attachment.js | 19 +++ app/model/role.js | 26 ++++ app/model/user.js | 39 ++++++ app/router.js | 50 +++++++ app/service/actionToken.js | 20 +++ app/service/role.js | 96 +++++++++++++ app/service/upload.js | 196 +++++++++++++++++++++++++++ app/service/user.js | 119 ++++++++++++++++ app/service/userAccess.js | 81 +++++++++++ appveyor.yml | 14 ++ config/config.default.js | 57 ++++++++ config/plugin.js | 26 ++++ package.json | 53 ++++++++ test/app/controller/home.test.js | 21 +++ 30 files changed, 1614 insertions(+) create mode 100644 .autod.conf.js create mode 100644 .eslintignore create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 README.zh-CN.md create mode 100644 app/controller/home.js create mode 100644 app/controller/role.js create mode 100644 app/controller/upload.js create mode 100644 app/controller/user.js create mode 100644 app/controller/userAccess.js create mode 100644 app/extend/RESTfulHTTPSStatus.rec create mode 100644 app/extend/helper.js create mode 100644 app/middleware/error_handler.js create mode 100644 app/model/attachment.js create mode 100644 app/model/role.js create mode 100644 app/model/user.js create mode 100644 app/router.js create mode 100644 app/service/actionToken.js create mode 100644 app/service/role.js create mode 100644 app/service/upload.js create mode 100644 app/service/user.js create mode 100644 app/service/userAccess.js create mode 100644 appveyor.yml create mode 100644 config/config.default.js create mode 100644 config/plugin.js create mode 100644 package.json create mode 100644 test/app/controller/home.test.js diff --git a/.autod.conf.js b/.autod.conf.js new file mode 100644 index 0000000..7c5ea4e --- /dev/null +++ b/.autod.conf.js @@ -0,0 +1,30 @@ +'use strict'; + +module.exports = { + write: true, + prefix: '^', + plugin: 'autod-egg', + test: [ + 'test', + 'benchmark', + ], + dep: [ + 'egg', + 'egg-scripts', + ], + devdep: [ + 'egg-ci', + 'egg-bin', + 'egg-mock', + 'autod', + 'autod-egg', + 'eslint', + 'eslint-config-egg', + 'webstorm-disable-index', + ], + exclude: [ + './test/fixtures', + './dist', + ], +}; + diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..4ebc8ae --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +coverage diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..a73b2b5 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": "eslint-config-egg", + "rules": { + "linebreak-style": [0, "error", "windows"] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14365a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +logs/ +npm-debug.log +yarn-error.log +node_modules/ +package-lock.json +yarn.lock +coverage/ +.idea/ +run/ +.DS_Store +*.sw* +*.un~ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b735efc --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +sudo: false +language: node_js +node_js: + - '8' +install: + - npm i npminstall && npminstall +script: + - npm run ci +after_script: + - npminstall codecov && codecov diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cba4467 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# restfulapi + + + +## QuickStart + + + +see [egg docs][egg] for more detail. + +### Development + +```bash +$ npm i +$ npm run dev +$ open http://localhost:7001/ +``` + +### Deploy + +```bash +$ npm start +$ npm stop +``` + +### npm scripts + +- Use `npm run lint` to check code style. +- Use `npm test` to run unit test. +- Use `npm run autod` to auto detect dependencies upgrade, see [autod](https://www.npmjs.com/package/autod) for more detail. + + +[egg]: https://eggjs.org \ No newline at end of file diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..ee6a640 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,98 @@ +## RESTful API + +以下 api 路径均以 http://127.0.0.1:7001/api 为前缀 + +### role 昵称 && 权限 😊 + +| Method | Path | Controller.Action | +| :----- | :-------- | :-------------------------- | +| GET | /role | app.controller.role.index | +| GET | /role/:id | app.controller.role.show | +| POST | /role | app.controller.role.create | +| PUT | /role/:id | app.controller.role.update | +| DELETE | /role/:id | app.controller.role.destroy | +| DELETE | /role | app.controller.role.removes | + +#### `GET /role` 用户管理首页 + +接受 get 参数 + +- pageSize Number 每一页数量 +- currentPage Number 当前页数 +- isPaging Boolean 是否分页 +- search String 检索内容 + 实例:/api/role + +#### `GET /role/:id` 查看特定用户 + +接受 URL 参数 + +#### `POST /role` 新建昵称 + +接受 post 参数 + +- name String +- access String +- extra (可选项) 额外说明 + +#### `PUT /role/:id` 更新信息 + +接受 put 参数 + +- name +- access + +### userAccess 用户登入/登出 😊 + +| Method | Path | Controller.Action | +| :----- | :-------------------- | :--------------------------------- | +| GET | /user/access/current | app.controller.userAccess.current | +| GET | /user/access/logout | app.controller.userAccess.logout | +| POST | /user/access/login | app.controller.userAccess.login | +| PUT | /user/access/resetPwd | app.controller.userAccess.resetPwd | + +> Token Authorization: Bearer token / OAuth 2.0 + +#### `GET /user/access/current` 用户详情 + +示例:/api/user/access/current + +#### `GET /user/access/logout` 退出登录 + +#### `POST /user/access/login` 用户登入 + +接受 post 参数 + +- mobile 手机号 required +- password 密码 required +- realName 用户真实姓名 +- avatar 用户头像 +- role 权限、昵称信息 +- extra + +> 暂未提供更换 avatar 接口 + +### user 用户管理 CRUD 😊 + +| Method | Path | Controller.Action | +| :----- | :-------- | :-------------------------- | +| GET | /user | app.controller.user.index | +| GET | /user/:id | app.controller.user.show | +| POST | /user | app.controller.user.create | +| PUT | /user/:id | app.controller.user.update | +| DELETE | /user/:id | app.controller.user.destroy | +| DELETE | /user | app.controller.user.removes | + +### upload 文件上传 (🙃 暂无测试) + +| Method | Path | Controller.Action | +| :----- | :---------------- | :----------------------------- | +| GET | /upload | app.controller.upload.index | +| GET | /upload/:id | app.controller.upload.show | +| POST | /upload | app.controller.upload.create | +| POST | /upload/url | app.controller.upload.url | +| POST | /uploads | app.controller.upload.multiple | +| PUT | /upload/:id | app.controller.upload.update | +| PUT | /upload/:id/extra | app.controller.upload.extrs | +| DELETE | /upload/:id | app.controller.upload.destroy | +| DELETE | /upload | app.controller.upload.removes | diff --git a/app/controller/home.js b/app/controller/home.js new file mode 100644 index 0000000..f900fd4 --- /dev/null +++ b/app/controller/home.js @@ -0,0 +1,11 @@ +'use strict'; + +const Controller = require('egg').Controller; + +class HomeController extends Controller { + async index() { + this.ctx.body = 'hi, egg'; + } +} + +module.exports = HomeController; diff --git a/app/controller/role.js b/app/controller/role.js new file mode 100644 index 0000000..47c912e --- /dev/null +++ b/app/controller/role.js @@ -0,0 +1,75 @@ +'use strict'; + +const Controller = require('egg').Controller; + +class RoleController extends Controller { + constructor(ctx) { + super(ctx); + this.createRule = { + name: { + type: 'string', + required: true, + allowEmpty: false, + }, + access: { + type: 'string', + required: true, + allowEmpty: false, + }, + }; + } + + async create() { + const { ctx, service } = this; + ctx.validate(this.createRule); + const payload = ctx.request.body || {}; + + const res = await service.role.create(payload); + ctx.helper.success({ ctx, res }); + } + + async destroy() { + const { ctx, service } = this; + const { id } = ctx.params; + await service.role.destroy(id); + ctx.helper.success({ ctx }); + } + + async update() { + const { ctx, service } = this; + ctx.validate(this.createRule); + const { id } = ctx.params; + const payload = ctx.request.body || {}; + + await service.role.update(id, payload); + ctx.helper.success({ ctx }); + } + + async show() { + const { ctx, service } = this; + const { id } = ctx.params; + const res = await service.role.show(id); + + ctx.helper.success({ ctx, res }); + } + + async index() { + const { ctx, service } = this; + const payload = ctx.query; + const res = await service.role.index(payload); + + ctx.helper.success({ ctx, res }); + } + + async removes() { + const { ctx, service } = this; + // {id: '5a452a44ab122b16a0231b42,5a452a3bab122b16a0231b41'} + const { id } = ctx.request.body; + const payload = id.split(',') || []; + await service.role.removes(payload); + + ctx.helper.success({ ctx }); + } +} + +module.exports = RoleController; diff --git a/app/controller/upload.js b/app/controller/upload.js new file mode 100644 index 0000000..1f95136 --- /dev/null +++ b/app/controller/upload.js @@ -0,0 +1,218 @@ +'use strict'; + +const Controller = require('egg').Controller; +const fs = require('fs'); +const path = require('path'); +const awaitWriteStream = require('await-stream-ready').write; +const sendToWormhole = require('stream-wormhole'); +const download = require('image-downloader'); + +class UploadController extends Controller { + // 单文件上传 + async create() { + const { ctx, service } = this; + // 要通过 ctx.getFileStream 便捷的获取用户上传的文件,需满足两个条件: + // 1. 只支持上传一个文件 + // 2. 上传文件必须在所有其他的 fields 后面,否则拿不到文件流时可能还获取不到 fields + const stream = await ctx.getFileStream(); + + // 所有表单字段都能通过 stream.fields 获取到 + const filename = path.basename(stream.filename); + const extname = path.extname(stream.fieldname).toLowerCase(); + + const attachment = new this.ctx.model.Attachment(); + attachment.filename = filename; + attachment.extname = extname; + attachment.url = `/uploads/${attachment._id.toString()}${extname}`; + + const target = path.join( + this.config.baseDir, + 'app/public/uploads', + `${attachment._id.toString()}${attachment.extname}` + ); + const writeStream = fs.createWriteStream(target); + + try { + await awaitWriteStream(stream.pipe(writeStream)); + } catch (err) { + await sendToWormhole(stream); + throw err; + } + + const res = await service.upload.create(attachment); + ctx.helper.success({ ctx, res }); + } + + async url() { + const { ctx, service } = this; + + const attachment = new this.ctx.model.Attachment(); + const { url } = ctx.request.body; + const filename = path.basename(url); + const extname = path.extname(url).toLowerCase(); + + const options = { + url, + dest: path.join( + this.config.baseDir, + 'app/public/uploads', + `${attachment._id.toString()}${extname}` + ), + }; + + let res; + try { + // 写入文件 + await download.image(options); + attachment.extname = extname; + attachment.filename = filename; + attachment.url = `/uploads/${attachment._id.toString()}${extname}`; + + res = await service.upload.create(attachment); + } catch (err) { + throw err; + } + + ctx.helper.success({ ctx, res }); + } + + // 多文件上传 + async multiple() { + const { ctx, service } = this; + const parts = ctx.multipart(); + const files = []; + + let part; // parts() return a promise + while ((part = await parts()) != null) { + if (part.length) { + // 如果是数组的话是 filed + // console.log('field: ' + part[0]) + // console.log('value: ' + part[1]) + // console.log('valueTruncated: ' + part[2]) + // console.log('fieldnameTruncated: ' + part[3]) + } else { + if (!part.filename) { + // 这时是用户没有选择文件就点击了上传(part 是 file stream, 但是 part.filename 为空) + // 需要作出处理, 例如给出错误提示信息 + return; + } + + // part 是上传的文件流 + // console.log('field: ' + part.fieldname) + // console.log('filename: ' + part.filename) + // console.log('extname: ' + part.extname) + // console.log('encoding: ' + part.encoding) + // console.log('mime: ' + part.mime) + const filename = part.filename.toLowerCase(); + const extname = part.extname(part.filename).toLowerCase(); + + const attachment = new ctx.model.Attachment(); + attachment.filename = filename; + attachment.extname = extname; + attachment.url = `/uploads/${attachment._id.toString()}${extname}`; + + const target = path.join( + this.config.baseDir, + 'app/public/uploads', + `${attachment._id.toString()}${extname}` + ); + const writeStream = fs.createWriteStream(target); + + try { + await awaitWriteStream(part.pipe(writeStream)); + await service.upload.create(attachment); + } catch (err) { + await sendToWormhole(part); + throw err; + } + + files.push(`${attachment._id}`); + } + } + + ctx.helper.success({ ctx, res: { _ids: files } }); + } + + async destroy() { + const { ctx, service } = this; + const { id } = ctx.params; + await service.upload.destroy(id); + + ctx.helper.success({ ctx }); + } + + // 修改单个文件 + async update() { + const { ctx, service } = this; + const { id } = ctx.params; + const attachment = await service.upload.updatePre(id); + + const stream = await ctx.getFileStream(); + const extname = path.extname(stream.filename).toLowerCase(); + const filename = path.basename(stream.filename); + + attachment.extname = extname; + attachment.filename = filename; + attachment.url = `/uploads/${attachment._id.toString()}${extname}`; + + const target_U = path.join( + this.config.baseDir, + 'app/public/uploads', + `${attachment._id}${extname}` + ); + const writeStream = fs.createWriteStream(target_U); + + try { + await awaitWriteStream(stream.pipe(writeStream)); + } catch (err) { + await sendToWormhole(stream); + throw err; + } + + // 保持原图片 _id 不变, 更新其他属性 + await service.upload.update(id, attachment); + ctx.helper.success({ ctx }); + } + + // 添加图片描述 + async extra() { + const { ctx, service } = this; + const { id } = ctx.params; + + const payload = ctx.request.body || {}; + await service.upload.extra(id, payload); + + ctx.helper.success({ ctx }); + } + + // 获取单个文件 + async show() { + const { ctx, service } = this; + const { id } = ctx.params; + const res = await service.upload.show(id); + + ctx.helper.success({ ctx, res }); + } + + // 获取所有文件 + async index() { + const { ctx, service } = this; + const payload = ctx.query; + const res = await service.upload.index(payload); + ctx.helper.success({ ctx, res }); + } + + // 删除所选文件(条件id[]) + async removes() { + const { ctx, service } = this; + const { id } = ctx.request.body; + const payload = id.split(',') || []; + + for (const attachment of payload) { + await service.upload.destroy(attachment); + } + ctx.helper.success({ ctx }); + } +} + +module.exports = UploadController; diff --git a/app/controller/user.js b/app/controller/user.js new file mode 100644 index 0000000..53dcaf1 --- /dev/null +++ b/app/controller/user.js @@ -0,0 +1,108 @@ +'use strict'; + +const Controller = require('egg').Controller; + +class UserController extends Controller { + constructor(ctx) { + super(ctx); + this.UserCreateTransfer = { + mobile: { + type: 'string', + required: true, + allowEmpty: false, + format: /(^1[34578]\d{9}$)|(^09\d{8}$)/, + }, + // 不能包含空格, 长度6-18位 + password: { + type: 'password', + required: true, + allowEmpty: false, + min: 6, + format: /^[\w\.\\\-\],?/|+*()[{}!"';<>@#$%^&`~=:]{6,18}$/, + }, + realName: { + type: 'string', + required: true, + allowEmpty: false, + format: /^[\u2E80-\u9FFF]{2,6}$/, + }, + }; + + this.UserUpdateTransfer = { + mobile: { + type: 'string', + required: true, + allowEmpty: false, + format: /(^1[34578]\d{9}$)|(^09\d{8}$)/, + }, + realName: { + type: 'string', + required: true, + allowEmpty: false, + format: /^[\u2E80-\u9FFF]{2,6}$/, + }, + }; + } + + // 创建用户 + async create() { + const { ctx, service } = this; + // 校验参数 + ctx.validate(this.UserCreateTransfer); + // 组装参数 + const payload = ctx.request.body || {}; + // 调用 service 进行业务处理 + const res = await service.user.create(payload); + // 设置响应内容和状态码 + ctx.helper.success({ ctx, res }); + } + + // 删除单个用户 + async destroy() { + const { ctx, service } = this; + const { id } = ctx.params; + + await service.user.destroy(id); + ctx.helper.success({ ctx }); + } + + // 修改用户 + async update() { + const { ctx, service } = this; + // 校验参数 + ctx.validate(this.UserUpdateTransfer); + const { id } = ctx.params; + const payload = ctx.request.body || {}; + + await service.user.update(id, payload); + + ctx.helper.success({ ctx }); + } + + // 获取单个用户 + async show() { + const { ctx, service } = this; + const { id } = ctx.params; + const res = await service.user.show(id); + ctx.helper.success({ ctx, res }); + } + + // 获取所有用户(分页/模糊) + async index() { + const { ctx, service } = this; + const payload = ctx.query; + const res = await service.user.index(payload); + ctx.helper.success({ ctx, res }); + } + + // 删除所选用户(id[]) + async removes() { + const { ctx, service } = this; + const { id } = ctx.request.body; + const payload = id.split(',') || []; + await service.user.removes(payload); + ctx.helper.success({ ctx }); + } +} + +module.exports = UserController; diff --git a/app/controller/userAccess.js b/app/controller/userAccess.js new file mode 100644 index 0000000..06132be --- /dev/null +++ b/app/controller/userAccess.js @@ -0,0 +1,137 @@ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const awaitWriteStream = require('await-stream-ready').write; +const sendToWormhole = require('stream-wormhole'); +const Controller = require('egg').Controller; + +class UserAccessController extends Controller { + constructor(ctx) { + super(ctx); + this.UserLoginTransfer = { + mobile: { + type: 'string', + required: true, + allowEmpty: false, + }, + password: { + type: 'string', + required: true, + allowEmpty: false, + }, + }; + + this.UserResetPwdTransfer = { + password: { + type: 'password', + required: true, + allowEmpty: false, + min: 6, + }, + oldPassword: { + type: 'password', + required: true, + allowEmpty: false, + min: 6, + }, + }; + + this.UserUpdateTransfer = { + mobile: { + type: 'string', + required: true, + allowEmpty: false, + }, + realName: { + type: 'string', + required: true, + allowEmpty: false, + format: /^[\u2E80-\u9FFF]{2,6}$/, + }, + }; + } + + // 用户登入 + async login() { + const { ctx, service } = this; + ctx.validate(this.UserLoginTransfer); + + const payload = ctx.request.body || {}; + + const res = await service.userAccess.login(payload); + + ctx.helper.success({ ctx, res }); + } + + // 用户登出 + async logout() { + const { ctx, service } = this; + await service.userAccess.logout(); + + ctx.helper.success({ ctx }); + } + + // 修改密码 + async resetPwd() { + const { ctx, service } = this; + + ctx.validate(this.UserResetPwdTransfer); + + const payload = ctx.request.body || {}; + + await service.userAccess.resetPwd(payload); + ctx.helper.success({ ctx }); + } + + // 获取用户信息 + async current() { + const { ctx, service } = this; + const res = await service.userAccess.current(); + + ctx.helper.success({ ctx, res }); + } + + // 修改基础信息 + async resetSelf() { + const { ctx, service } = this; + ctx.validate(this.UserUpdateTransfer); + + const payload = ctx.request.body || {}; + await service.userAccess.resetSelf(payload); + + ctx.helper.success({ ctx }); + } + + // 修改头像 + async resetAvatar() { + const { ctx, service } = this; + const stream = await ctx.getFileStream(); + const filename = path.basename(stream.filename); + const extname = path.extname(stream.filename).toLowerCase(); + const attachment = new this.ctx.model.Attachment(); + + attachment.extname = extname; + attachment.filename = filename; + attachment.url = `/uploads/avatar/${attachment._id.toString()}${extname}`; + + const target = path.join( + this.config.baseDir, + 'app/public/uploads/avatar', + `${attachment._id.toString()}${attachment.extname}` + ); + const writeStream = fs.createWriteStream(target); + + try { + await awaitWriteStream(stream.pipe(writeStream)); + + await service.userAccess.resetAvatar(attachment); + } catch (err) { + await sendToWormhole(stream); + throw err; + } + + ctx.helper.success({ ctx }); + } +} + +module.exports = UserAccessController; diff --git a/app/extend/RESTfulHTTPSStatus.rec b/app/extend/RESTfulHTTPSStatus.rec new file mode 100644 index 0000000..cdca5d8 --- /dev/null +++ b/app/extend/RESTfulHTTPSStatus.rec @@ -0,0 +1,14 @@ +200 OK - [GET]: 服务器成功返回用户请求数据,该操作是幂等的(Idempotent) +201 CREATED - [POST/PUT/PATCH]: 用户新建或修改数据成功 +202 Accepted - [*]: 表示一个请求已经进入后台排列(异步任务) +204 NO CONTENT - [DELETE]: 用户删除数据成功 + +400 INVALID REQUEST - [POST/PUT/PATCH]: 用户发出的请求有错误,服务器没有进行新建或修改数据的操作,该操作是幂等的 +401 Unauthorized - [*]: 表示用户没有权限(令牌、用户名、密码错误) +403 Forbidden - [*]: 表示用户得到授权(与401错误相对), 但是访问是被禁止的 +404 NOT FOUND - [*]: 用户发出的请求针对的是不存在的记录,服务器没有进行操作,该操作是幂等的 +406 Not Acceptable - [GET]: 用户请求的格式不可得(比如用户请求JSON格式,但只有XML格式) +410 Gone - [GET]: 资源别永久删除,且不会再得到 +422 Unprocesable Entity - [POST/PUT/PATCH]: 当创建是一个对象时, 发生验证错误 + +500 INTERNAL SERVER ERROR - [*]: 服务器发生错误, 用户将无法判断发出的请求是否成功 \ No newline at end of file diff --git a/app/extend/helper.js b/app/extend/helper.js new file mode 100644 index 0000000..577d9f0 --- /dev/null +++ b/app/extend/helper.js @@ -0,0 +1,15 @@ +'use strict'; + +const moment = require('moment'); + +// 格式化时间 +exports.formatTime = time => moment(time).format('YYYY-MM-DD hh:mm:ss'); + +exports.success = ({ ctx, res = null, msg = '请求成功' }) => { + ctx.body = { + code: 0, + data: res, + msg, + }; + ctx.status = 200; +}; diff --git a/app/middleware/error_handler.js b/app/middleware/error_handler.js new file mode 100644 index 0000000..83feb27 --- /dev/null +++ b/app/middleware/error_handler.js @@ -0,0 +1,27 @@ +'use strict'; + +module.exports = (opts, app) => + async function(ctx, next) { + try { + await next(); + } catch (err) { + // 所有的异常都在 app 上触发error事件,框架会记录一条错误日志 + app.emit('error', err, this); + const status = err.status || 500; + // 生产环境500错误详细内容不返回给客户端,因为可能包含敏感信息 + const error = + status === 500 && app.config.env === 'prod' + ? 'Internal Server Errror' + : err.message; + + ctx.body = { + code: status, + error, + }; + + if (status === 422) { + ctx.body.detail = err.errors; + } + ctx.status = 200; + } + }; diff --git a/app/model/attachment.js b/app/model/attachment.js new file mode 100644 index 0000000..99c2fb2 --- /dev/null +++ b/app/model/attachment.js @@ -0,0 +1,19 @@ +'use strict'; + +module.exports = app => { + const mongoose = app.mongoose; + const Schema = mongoose.Schema; + + const AttachmentSchema = new Schema({ + extname: String, + url: String, + filename: String, + extra: String, + createdAt: { + type: Date, + default: Date.now, + }, + }); + + return mongoose.model('Attachment', AttachmentSchema); +}; diff --git a/app/model/role.js b/app/model/role.js new file mode 100644 index 0000000..17d8099 --- /dev/null +++ b/app/model/role.js @@ -0,0 +1,26 @@ +'use strict'; +module.exports = app => { + const mongoose = app.mongoose; + const Schema = mongoose.Schema; + + const RoleSchema = new Schema({ + name: { + type: String, + unique: true, + required: true, + }, + access: { + type: String, + required: true, + default: 'user', + }, + extra: { + type: mongoose.Schema.Types.Mixed, + }, + createdAt: { + type: Date, + default: Date.now, + }, + }); + return mongoose.model('Role', RoleSchema); +}; diff --git a/app/model/user.js b/app/model/user.js new file mode 100644 index 0000000..06c79ec --- /dev/null +++ b/app/model/user.js @@ -0,0 +1,39 @@ +'use strict'; + +module.exports = app => { + const mongoose = app.mongoose; + const Schema = mongoose.Schema; + + const UserSchema = new Schema({ + mobile: { + type: String, + unique: true, + required: true, + }, + password: { + type: String, + required: true, + }, + realName: { + type: String, + required: true, + }, + role: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Role', + }, + avatar: { + type: String, + default: 'https://avatars3.githubusercontent.com/u/45113594?s=40&v=4', + }, + extra: { + type: mongoose.Schema.Types.Mixed, + }, + createdAt: { + type: Date, + default: Date.now, + }, + }); + + return mongoose.model('User', UserSchema); +}; diff --git a/app/router.js b/app/router.js new file mode 100644 index 0000000..305ef0d --- /dev/null +++ b/app/router.js @@ -0,0 +1,50 @@ +'use strict'; + +/** + * @param {Egg.Application} app - egg application + */ +module.exports = app => { + const { router, controller } = app; + + router.get('/', controller.home.index); + + // role CRUD + router.delete('/api/role', controller.role.removes); + router.resources('role', '/api/role', controller.role); + + // userAccess + router.post('/api/user/access/login', controller.userAccess.login); + router.get( + '/api/user/access/current', + app.jwt, + controller.userAccess.current + ); + router.get('/api/user/access/logout', controller.userAccess.logout); + router.put( + '/api/user/access/resetPwd', + app.jwt, + controller.userAccess.resetPwd + ); + + // user + router.delete('/api/user', controller.user.removes); + router.resources('user', '/api/user', controller.user); + + // upload + // 单文件上传 + router.post('/api/upload', controller.upload.create); + router.post('/api/upload/url', controller.upload.url); + // 多文件上传 + router.post('/api/uploads', controller.upload.multiple); + // 单文件删除 + router.delete('/api/upload/:id', controller.upload.destroy); + // 单文件更新 + router.put('/api/upload/:id', controller.upload.update); + router.put('/api/upload/:id/extra', controller.upload.extra); + // 获取单文件 + router.get('/api/upload/:id', controller.upload.show); + // 获取所有 + router.get('/api/upload', controller.upload.index); + // 删除所有 + router.delete('/api/upload', controller.upload.removes); +}; diff --git a/app/service/actionToken.js b/app/service/actionToken.js new file mode 100644 index 0000000..eabab25 --- /dev/null +++ b/app/service/actionToken.js @@ -0,0 +1,20 @@ +'use strict'; + +const Service = require('egg').Service; + +class ActionTokenService extends Service { + async apply(_id) { + const { ctx } = this; + return ctx.app.jwt.sign( + { + data: { + _id, + }, + exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7, + }, + ctx.app.config.jwt.secret + ); + } +} + +module.exports = ActionTokenService; diff --git a/app/service/role.js b/app/service/role.js new file mode 100644 index 0000000..506a17c --- /dev/null +++ b/app/service/role.js @@ -0,0 +1,96 @@ +'use strict'; + +const Service = require('egg').Service; + +class RoleService extends Service { + async create(payload) { + return this.ctx.model.Role.create(payload); + } + + async destroy(_id) { + const { ctx } = this; + const role = await ctx.service.role.find(_id); + if (!role) { + ctx.throw(404, 'role not found'); + } + return ctx.model.Role.findByIdAndRemove(_id); + } + + async update(_id, payload) { + const { ctx } = this; + const role = await ctx.service.role.find(_id); + if (!role) { + ctx.throw(404, 'role is not found'); + } + return ctx.model.Role.findByIdAndUpdate(_id, payload); + } + + async show(_id) { + const role = this.ctx.service.role.find(_id); + if (!role) { + this.ctx.throw(404, 'role is not found'); + } + return this.ctx.model.Role.findById(_id); + } + + async index(payload) { + const { currentPage, pageSize, isPaging, search } = payload; + let res = []; + let count = 0; + const skip = (Number(currentPage) - 1) * Number(pageSize || 10); + + if (isPaging) { + if (search) { + res = await this.ctx.model.Role.find({ name: { $regex: search } }) + .skip(skip) + .limit(Number(pageSize)) + .sort({ createdAt: -1 }) + .exec(); + count = res.length; + } else { + res = await this.ctx.model.Role.find({}) + .skip(skip) + .limit(Number(pageSize)) + .sort({ createdAt: -1 }) + .exec(); + count = res.length; + } + } else { + if (search) { + res = await this.ctx.model.Role.find({ name: { $regex: search } }) + .sort({ createdAt: -1 }) + .exec(); + count = res.length; + } else { + res = await this.ctx.model.Role.find({}) + .sort({ createdAt: -1 }) + .exec(); + count = res.length; + } + } + + const data = res.map((e, j) => { + const jsonObject = Object.assign({}, e._doc); + jsonObject.key = j; + jsonObject.createdAt = this.ctx.helper.formatTime(e.createdAt); + return jsonObject; + }); + + return { + count, + list: data, + pageSize: Number(pageSize), + currentPage: Number(currentPage), + }; + } + + async removes(values) { + return this.ctx.model.Role.remove({ _id: { $in: values } }); + } + + async find(id) { + return this.ctx.model.Role.findById(id); + } +} + +module.exports = RoleService; diff --git a/app/service/upload.js b/app/service/upload.js new file mode 100644 index 0000000..b470a61 --- /dev/null +++ b/app/service/upload.js @@ -0,0 +1,196 @@ +'use strict'; + +const Service = require('egg').Service; +const fs = require('fs'); +const path = require('path'); + +class UploadService extends Service { + async create(payload) { + return this.ctx.model.Attachment.create(payload); + } + + async destroy(_id) { + const { ctx } = this; + const attachment = await ctx.service.upload.find(_id); + if (!attachment) { + ctx.throw(404, 'attachment not found'); + } else { + const target = path.join( + this.config.baseDir, + 'app/public/uploads', + `${attachment._id}${attachment.extname}` + ); + fs.unlinkSync(target); + } + + return ctx.model.Attachment.findByIdAndRemove(_id); + } + + async updatePre(_id) { + const { ctx } = this; + const attachment = await ctx.service.upload.find(_id); + if (!attachment) { + ctx.throw(404, 'attachment not found'); + } else { + const target = path.join( + this.config.baseDir, + 'app/public/uploads', + `${attachment._id}${attachment.extname}` + ); + fs.unlinkSync(target); + } + return attachment; + } + + async extra(_id, values) { + const { ctx } = this; + const attachment = await ctx.service.upload.find(_id); + if (!attachment) { + ctx.throw(404, 'attachment not found'); + } + return ctx.model.Attachment.findByIdAndUpdate(_id, values); + } + + async show(_id) { + const attachment = await this.ctx.service.upload.find(_id); + if (!attachment) { + this.ctx.throw(404, 'attachment not found'); + } + return this.ctx.model.Attachment.findById(_id); + } + + async index(payload) { + // 支持全部all 无需传入kind + // 图像kind = image ['.jpg', '.jpeg', '.png', '.gif'] + // 文档kind = document ['.doc', '.docx', '.ppt', '.pptx', '.xls', '.xlsx', '.csv', '.key', '.numbers', '.pages', '.pdf', '.txt', '.psd', '.zip', '.gz', '.tgz', '.gzip' ] + // 视频kind = video ['.mov', '.mp4', '.avi'] + // 音频kind = audio ['.mp3', '.wma', '.wav', '.ogg', '.ape', '.acc'] + const attachmentKind = { + image: [ '.jpg', '.jpeg', '.png', 'gif' ], + document: [ + '.doc', + '.docx', + '.ppt', + '.pptx', + '.xls', + '.xlsx', + '.csv', + '.key', + '.numbers', + '.pages', + '.pdf', + '.txt', + '.psd', + '.zip', + '.7z', + '.gzip', + '.gz', + '.tgz', + ], + video: [ '.mov', '.mp4', '.avi' ], + audio: [ '.mp3', '.wma', '.wav', '.ogg', '.ape', '.acc' ], + }; + + const { currentPage, pageSize, isPaging, search, kind } = payload; + let res = []; + let count = 0; + const skip = (Number(currentPage) - 1) * Number(pageSize || 10); + if (isPaging) { + if (search) { + if (kind) { + res = await this.ctx.model.Attachment.find({ + filename: { $regex: search }, + extname: { $in: attachmentKind[`${kind}`] }, + }) + .skip(skip) + .limit(Number(pageSize)) + .sort({ createdAt: -1 }) + .exec(); + } else { + res = await this.ctx.model.Attachment.find({ + filename: { $regex: search }, + }) + .skip(skip) + .limit(Number(pageSize)) + .sort({ createdAt: -1 }) + .exec(); + } + count = res.length; + } else { + if (kind) { + res = await this.ctx.model.Attachment.find({ + extname: { $in: attachmentKind[`${kind}`] }, + }) + .skip(skip) + .limit(Number(pageSize)) + .sort({ createdAt: -1 }) + .exec(); + count = await this.ctx.model.Attachment.count({ + extname: { $in: attachmentKind[`${kind}`] }, + }).exec(); + } else { + res = await this.ctx.model.Attachment.find({}) + .skip(skip) + .limit(Number(pageSize)) + .sort({ createdAt: -1 }) + .exec(); + count = await this.ctx.model.Attachment.count({}).exec(); + } + } + } else { + if (search) { + if (kind) { + res = await this.ctx.model.Attachment.find({ + filename: { $regex: search }, + extname: { $in: attachmentKind[`${kind}`] }, + }) + .sort({ createdAt: -1 }) + .exec(); + } else { + res = await this.ctx.model.Attachment.find({ + filename: { $regex: search }, + }) + .sort({ createdAt: -1 }) + .exec(); + } + count = res.length; + } else { + if (kind) { + res = await this.ctx.model.Attachment.find({ + extname: { $in: attachmentKind[`${kind}`] }, + }) + .sort({ createdAt: -1 }) + .exec(); + count = await this.ctx.model.Attachment.count({ + extname: { $in: attachmentKind[`${kind}`] }, + }).exec(); + } else { + res = await this.ctx.model.Attachment.find({}) + .sort({ createdAt: -1 }) + .exec(); + count = await this.ctx.model.Attachment.count({}).exec(); + } + } + } + + const data = res.map((e, i) => { + const jsonObject = Object.assign({}, e._doc); + jsonObject.key = i; + jsonObject.createdAt = this.ctx.helper.formatTime(e.createdAt); + return jsonObject; + }); + + return { + count, + list: data, + pageSize: Number(pageSize), + currentPage: Number(currentPage), + }; + } + + async find(id) { + return this.ctx.model.Attachment.findById(id); + } +} + +module.exports = UploadService; diff --git a/app/service/user.js b/app/service/user.js new file mode 100644 index 0000000..c4e5e25 --- /dev/null +++ b/app/service/user.js @@ -0,0 +1,119 @@ +'use strict'; +const Service = require('egg').Service; + +class UserService extends Service { + // create ========================= + async create(payload) { + const { ctx, service } = this; + const role = await service.role.show(payload.role); + if (!role) { + ctx.throw(404, 'role is not found'); + } + payload.password = await this.ctx.genHash(payload.password); + return ctx.model.User.create(payload); + } + + // destroy ======================== + async destroy(_id) { + const { ctx } = this; + const user = await ctx.service.user.find(_id); + if (!user) { + ctx.throw(404, 'user not found'); + } + return ctx.model.User.findByIdAndRemove(_id); + } + + // update ========================= + async update(_id, payload) { + const { ctx } = this; + const user = await ctx.service.user.find(_id); + if (!user) { + ctx.throw(404, 'user not found'); + } + return ctx.model.User.findByIdAndUpdate(_id, payload); + } + + // show =========================== + async show(_id) { + const user = this.service.user.find(_id); + if (!user) { + this.ctx.throw(404, 'user is not found'); + } + return this.ctx.model.User.findById(_id).populate('role'); + } + + // index ========================== + async index(payload) { + const { currentPage, pageSize, isPaging, search } = payload; + let res = []; + let count = 0; + const skip = (Number(currentPage) - 1) * Number(pageSize || 10); + if (isPaging) { + if (search) { + res = await this.ctx.model.User.find({ mobile: { $regex: search } }) + .populate('role') + .skip(skip) + .limit(Number(pageSize)) + .sort({ createAt: -1 }) + .exec(); + count = res.length; + } else { + res = await this.ctx.model.User.find({}) + .populate('role') + .skip(skip) + .limit(Number(pageSize)) + .sort({ createAt: -1 }) + .exec(); + count = res.length; + } + } else { + if (search) { + res = await this.ctx.model.User.find({ mobile: { $regex: search } }) + .populate('role') + .sort({ createAt: -1 }) + .exec(); + count = res.length; + } else { + res = await this.ctx.model.User.find({}) + .populate('role') + .sort({ createAt: -1 }) + .exec(); + count = res.length; + } + } + + const data = res.map((e, i) => { + const jsonObject = Object.assign({}, e._doc); + jsonObject.key = i; + jsonObject.password = 'Are you Ok?'; + jsonObject.createAt = this.ctx.helper.formatTime(e.createAt); + return jsonObject; + }); + return { + count, + list: data, + pageSize: Number(pageSize), + currentPage: Number(currentPage), + }; + } + + // removes ======================= + async removes(payload) { + return this.ctx.model.User.remove({ _id: { $in: payload } }); + } + + // common ======================== + async findByMobile(mobile) { + return this.ctx.model.User.findOne({ mobile }); + } + + async find(id) { + return this.ctx.model.User.findById(id); + } + + async findByIdAndUpdate(id, values) { + return this.ctx.model.User.findByIdAndUpdate(id, values); + } +} + +module.exports = UserService; diff --git a/app/service/userAccess.js b/app/service/userAccess.js new file mode 100644 index 0000000..2226b26 --- /dev/null +++ b/app/service/userAccess.js @@ -0,0 +1,81 @@ +'use strict'; + +const Service = require('egg').Service; + +class UserAccessService extends Service { + async login(payload) { + const { ctx, service } = this; + const user = await service.user.findByMobile(payload.mobile); + if (!user) { + ctx.throw(404, 'user not found'); + } + + const verifyPwd = await ctx.compare(payload.password, user.password); + if (!verifyPwd) { + ctx.throw(404, 'user password is error'); + } + + // 生成Token令牌 + return { token: await service.actionToken.apply(user._id) }; + } + + async resetPwd(values) { + const { ctx, service } = this; + // ctx.state.user 可以提取 JWT 编码的 data + const _id = ctx.state.user.data._id; + const user = await service.user.find(_id); + + if (!user) { + ctx.throw(404, 'user is not found'); + } + + const verifyPwd = await ctx.compare(values.oldPassword, user.password); + if (!verifyPwd) { + ctx.throw(404, 'user password error'); + } else { + values.password = await ctx.genHash(values.password); + return service.user.findByIdAndUpdate(_id, values); + } + } + + async current() { + const { ctx, service } = this; + const _id = ctx.state.user.data._id; + const user = await service.user.find(_id); + + if (!user) { + ctx.throw(404, 'user is not found'); + } + user.password = 'How old are you?'; + return user; + } + + async resetSelf(values) { + const { ctx, service } = this; + const _id = ctx.state.user.data._id; + const user = await service.user.find(_id); + if (!user) { + ctx.throw(404, 'user is not found'); + } + return service.user.findByIdAndUpdate(_id, values); + } + + async resetAvatar(values) { + const { ctx, service } = this; + await service.upload.create(values); + + const _id = ctx.state.user.data._id; + const user = await service.user.find(_id); + if (!user) { + ctx.throw(404, 'user is not found'); + } + return service.user.findByIdAndUpdate(_id, { avatar: values.url }); + } + + async logout() { + // 客户端清除 Token 即可退出登录 + console.log('logout'); + } +} + +module.exports = UserAccessService; diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..c274b7d --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,14 @@ +environment: + matrix: + - nodejs_version: '8' + +install: + - ps: Install-Product node $env:nodejs_version + - npm i npminstall && node_modules\.bin\npminstall + +test_script: + - node --version + - npm --version + - npm run test + +build: off diff --git a/config/config.default.js b/config/config.default.js new file mode 100644 index 0000000..cd5a667 --- /dev/null +++ b/config/config.default.js @@ -0,0 +1,57 @@ +'use strict'; + +module.exports = appInfo => { + const config = (exports = {}); + config.keys = appInfo.name + 'Fw6IsAHandsomeBoY'; + + // add your config here + config.middleware = [ 'errorHandler' ]; + + // 只对 /api 前缀的 URL 路径有效 + config.errorHandler = { + match: '/api', + }; + + // 安全验证 + config.security = { + csrf: { + enable: false, + }, + // 白名单 + domainWhiteList: [ 'http://localhost:8000' ], + }; + + config.multipart = { + fileExtensions: [ + '.apk', + '.pptx', + '.docx', + '.doc', + '.ppt', + '.csv', + '.pdf', + '.pages', + '.wav', + '.mov', + ], + }; + + config.bcrypt = { + saltRounds: 10, + }; + + config.mongoose = { + client: { + url: 'mongodb://127.0.0.1:27017/restfulapi', + options: { useNewUrlParser: true }, + }, + }; + + config.jwt = { + secret: 'Great4-M', + enable: true, + match: '/jwt', + }; + + return config; +}; diff --git a/config/plugin.js b/config/plugin.js new file mode 100644 index 0000000..12af9b1 --- /dev/null +++ b/config/plugin.js @@ -0,0 +1,26 @@ +'use strict'; + +exports.validate = { + enable: true, + package: 'egg-validate', +}; + +exports.bcrypt = { + enable: true, + package: 'egg-bcrypt', +}; + +exports.mongoose = { + enable: true, + package: 'egg-mongoose', +}; + +exports.jwt = { + enable: true, + package: 'egg-jwt', +}; + +exports.cors = { + enable: true, + package: 'egg-cors', +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..ee2028d --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "restfulapi", + "version": "1.0.0", + "description": "", + "private": true, + "dependencies": { + "await-stream-ready": "^1.0.1", + "egg": "^2.2.1", + "egg-bcrypt": "^1.1.0", + "egg-cors": "^2.1.2", + "egg-jwt": "^3.1.4", + "egg-mongoose": "^3.1.1", + "egg-scripts": "^2.5.0", + "egg-validate": "^2.0.2", + "image-downloader": "^3.4.2", + "moment": "^2.23.0", + "stream-wormhole": "^1.1.0" + }, + "devDependencies": { + "autod": "^3.0.1", + "autod-egg": "^1.0.0", + "egg-bin": "^4.3.5", + "egg-ci": "^1.8.0", + "egg-mock": "^3.14.0", + "eslint": "^4.11.0", + "eslint-config-egg": "^6.0.0", + "webstorm-disable-index": "^1.2.0" + }, + "engines": { + "node": ">=8.9.0" + }, + "scripts": { + "start": "egg-scripts start --daemon", + "stop": "egg-scripts stop", + "dev": "egg-bin dev", + "debug": "egg-bin debug", + "test": "npm run lint -- --fix && npm run test-local", + "test-local": "egg-bin test", + "cov": "egg-bin cov", + "lint": "eslint .", + "ci": "npm run lint && npm run cov", + "autod": "autod" + }, + "ci": { + "version": "8" + }, + "repository": { + "type": "git", + "url": "" + }, + "author": "", + "license": "MIT" +} diff --git a/test/app/controller/home.test.js b/test/app/controller/home.test.js new file mode 100644 index 0000000..bcafc4a --- /dev/null +++ b/test/app/controller/home.test.js @@ -0,0 +1,21 @@ +'use strict'; + +const { app, assert } = require('egg-mock/bootstrap'); + +describe('test/app/controller/home.test.js', () => { + + it('should assert', function* () { + const pkg = require('../../../package.json'); + assert(app.config.keys.startsWith(pkg.name)); + + // const ctx = app.mockContext({}); + // yield ctx.service.xx(); + }); + + it('should GET /', () => { + return app.httpRequest() + .get('/') + .expect('hi, egg') + .expect(200); + }); +});