Design a Node.js work order system based on GraphQL

  • MySQL database storage, Redis cache
  • OAuth authentication
  • Dataloader data query optimization
  • GraphQL underlying interface data engine

Table Structure

The database uses MySQL, and the core two tables are 工单and 回复.

CREATE TABLE IF NOT EXISTS `xibang`.`d_ticket` (
  `tid` varchar(40) NOT NULL DEFAULT '' COMMENT '工单id',
  `uid` int(11) unsigned NOT NULL COMMENT '提交用户id',
  `status` enum('open','closed') NOT NULL DEFAULT 'open' COMMENT '开闭状态',
  `reply` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '回复状态',
  `type` varchar(32) NOT NULL DEFAULT 'bug' COMMENT '类型',
  `notify` enum('mobile','email','both','none') NOT NULL DEFAULT 'email' COMMENT '通知方式',
  `title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题',
  `body` blob NOT NULL COMMENT '描述',
  `createdAt` int(10) unsigned NOT NULL COMMENT '创建时间',
  `updatedAt` int(10) unsigned NOT NULL COMMENT '操作时间',
  PRIMARY KEY (`tid`),
  KEY `uid` (`uid`),
  KEY `createdAt` (`createdAt`),
  KEY `status` (`status`),
  KEY `type` (`type`),
  KEY `reply` (`reply`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

There are two types of work order status:

  • Open
  • closure

There are two types of reply states:

  • 0: no reply
  • 1: Reply
CREATE TABLE IF NOT EXISTS `xibang`.`d_ticketreply` (
  `tid` varchar(40) NOT NULL DEFAULT '' COMMENT '工单id',
  `uid` int(11) unsigned NOT NULL COMMENT '回复人用户id',
  `body` blob NOT NULL COMMENT '回复内容',
  `createdAt` int(10) unsigned NOT NULL COMMENT '回复时间',
  `updatedAt` int(10) unsigned NOT NULL COMMENT '最后修改时间',
  KEY `tid` (`tid`),
  KEY `createdAt` (`createdAt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

GraphQL Schema

print script:

const {
    
     printSchema } = require("graphql");
const schema = require("../src/graphql");

console.log(printSchema(schema));

The full GraphQL structure:

"""
Root mutation object
"""
type Mutation {
  createTicket(input: TicketCreateInput!): Ticket
  updateTicket(input: TicketUpdateInput!): Ticket
  createReply(input: ReplyCreateInput!): TicketReply
  updateReply(input: ReplyUpdateInput!): TicketReply
}

"""
An object with an ID
"""
interface Node {
  """
  The id of the object.
  """
  id: ID!
}

type Owner implements Node {
  """
  The ID of an object
  """
  id: ID!
  uid: Int!
  oid: Int!
  username: String!
  mobile: String!
  email: String!
  createdAt: Int!
  avatar: String!
  verified: Boolean!
  isAdmin: Boolean!
}

"""
Information about pagination in a connection.
"""
type PageInfo {
  """
  When paginating forwards, are there more items?
  """
  hasNextPage: Boolean!

  """
  When paginating backwards, are there more items?
  """
  hasPreviousPage: Boolean!

  """
  When paginating backwards, the cursor to continue.
  """
  startCursor: String

  """
  When paginating forwards, the cursor to continue.
  """
  endCursor: String
}

"""
Root query object
"""
type Query {
  viewer: User
  ticket(
    """
    Ticket ID
    """
    tid: String!
  ): Ticket
  tickets(
    """
    Ticket Owner User ID
    """
    uid: Int

    """
    Ticket Open Status
    """
    status: TicketStatus

    """
    Ticket Type
    """
    type: TicketNotify

    """
    Ticket Reply Status
    """
    reply: Boolean
    after: String
    first: Int
    before: String
    last: Int
  ): TicketsConnection
}

"""
A connection to a list of items.
"""
type RepliesConnection {
  """
  Information to aid in pagination.
  """
  pageInfo: PageInfo!

  """
  A list of edges.
  """
  edges: [RepliesEdge]

  """
  A count of the total number of objects in this connection, ignoring pagination.
  """
  totalCount: Int

  """
  A list of all of the objects returned in the connection.
  """
  replies: [TicketReply]
}

"""
An edge in a connection.
"""
type RepliesEdge {
  """
  The item at the end of the edge
  """
  node: TicketReply

  """
  A cursor for use in pagination
  """
  cursor: String!
}

"""
Input reply payload
"""
input ReplyCreateInput {
  """
  Ticket ID
  """
  tid: String!

  """
  Reply Content
  """
  body: String!
}

"""
Input reply payload
"""
input ReplyUpdateInput {
  """
  Ticket ID
  """
  tid: String!

  """
  Reply Content
  """
  body: String!

  """
  Reply createdAt
  """
  createdAt: Int!
}

type Ticket implements Node {
  """
  The ID of an object
  """
  id: ID!
  tid: String!
  uid: Int!
  status: TicketStatus!
  reply: Boolean!
  type: String!
  notify: TicketNotify!
  title: String!
  body: String!
  createdAt: Int!
  updatedAt: Int!
  replies(
    after: String
    first: Int
    before: String
    last: Int
  ): RepliesConnection
  owner: Owner
}

"""
Input ticket payload
"""
input TicketCreateInput {
  """
  Ticket Type
  """
  type: String!

  """
  Ticket Notification Type
  """
  notify: TicketNotify!

  """
  Ticket Title
  """
  title: String!

  """
  Ticket Content
  """
  body: String!
}

enum TicketNotify {
  mobile
  email
  both
  none
}

type TicketReply implements Node {
  """
  The ID of an object
  """
  id: ID!
  tid: String!
  uid: Int!
  body: String!
  createdAt: Int!
  updatedAt: Int!
  owner: Owner
}

"""
A connection to a list of items.
"""
type TicketsConnection {
  """
  Information to aid in pagination.
  """
  pageInfo: PageInfo!

  """
  A list of edges.
  """
  edges: [TicketsEdge]

  """
  A count of the total number of objects in this connection, ignoring pagination.
  """
  totalCount: Int

  """
  A list of all of the objects returned in the connection.
  """
  tickets: [Ticket]
}

"""
An edge in a connection.
"""
type TicketsEdge {
  """
  The item at the end of the edge
  """
  node: Ticket

  """
  A cursor for use in pagination
  """
  cursor: String!
}

enum TicketStatus {
  open
  closed
}

"""
Input ticket payload
"""
input TicketUpdateInput {
  """
  TicketID
  """
  tid: String!

  """
  Ticket Open Status
  """
  status: TicketStatus

  """
  Ticket Type
  """
  type: String

  """
  Ticket Notify Status
  """
  notify: TicketNotify

  """
  Ticket Title
  """
  title: String

  """
  Ticket Body
  """
  body: String
}

type User implements Node {
  """
  The ID of an object
  """
  id: ID!
  uid: Int!
  oid: Int!
  username: String!
  mobile: String!
  email: String!
  createdAt: Int!
  avatar: String!
  verified: Boolean!
  isAdmin: Boolean!
  tickets(
    """
    Ticket Owner User ID
    """
    uid: Int

    """
    Ticket Open Status
    """
    status: TicketStatus

    """
    Ticket Type
    """
    type: TicketNotify

    """
    Ticket Reply Status
    """
    reply: Boolean
    after: String
    first: Int
    before: String
    last: Int
  ): TicketsConnection
}

Permission settings

Query section:

  • Viewer
    • Users query their own information
    • Query your work order
  • Ticket
    • Admin query all work orders
    • Users query their work orders
    • Query Ticket Reply
  • Tickets
    • Users have no permission, only administrators can query all work orders

Mutation section:

  • Create work order
  • Update ticket: user operates own, administrator operates (closes, reopens) all
  • add reply
  • update reply
{
    
    
  Query: {
    
    
    viewer: {
    
    
      // 用户(管理员)查询自己的
      tickets: {
    
    
        // 用户查询自己的工单
      }
    },
    ticket: {
    
    
      // 用户查询自己的,管理员查询所有
      replies: {
    
    

      }
    },
    tickets: {
    
    
      // 用户无权限,管理员查询所有
      // 用户查询自己的工单从 viewer 下进行
    }
  },
  Mutation: {
    
    
    addTicket: '用户',
    updateTicket: '用户操作自己的,管理员操作(关闭、重新打开)所有',
    addReply: '用户',
    updateReply: '用户(管理员)操作自己的'
  }
}

Code

Authenticate in Root.

Query section

const {
    
    
  GraphQLObjectType, GraphQLNonNull, GraphQLString
} = require('graphql');
const {
    
     type: UserType } = require('./types/user');
const {
    
     type: TicketType, args: TicketArgs } = require('./types/ticket');
const connection = require('./interfaces/connection');
const {
    
     getObject } = require('./loaders');

module.exports = new GraphQLObjectType({
    
    
  name: 'Query',
  description: 'Root query object',
  fields: {
    
    
    viewer: {
    
    
      type: UserType,
      resolve: (_, args, ctx) => {
    
    
        const {
    
     uid } = ctx.session;
        return getObject({
    
     type: 'user', id: uid });
      }
    },
    ticket: {
    
    
      type: TicketType,
      args: {
    
    
        tid: {
    
    
          description: 'Ticket ID',
          type: new GraphQLNonNull(GraphQLString)
        }
      },
      resolve: (_, args, ctx) => getObject({
    
     id: args.tid, type: 'ticket' }).then((data) => {
    
    
        const {
    
     uid } = ctx.session;
        // TODO: Admin Auth Check
        // data.uid !== uid && user is not admin
        if (data.uid !== uid) {
    
    
          return null;
        }
        return data;
      })
    },
    tickets: connection('Tickets', TicketType, TicketArgs)
  }
});

The verification of permissions is performed here. You can judge whether it is your own work order through the user uid, or you can do the administrator's verification here.

Mutation section

const {
    
     GraphQLObjectType } = require('graphql');
const {
    
     type: TicketType, input: TicketInputArgs, inputOperation: TicketUpdateInputArgs } = require('./types/ticket');
const {
    
     type: ReplyType, input: ReplyInputArgs, inputUpdate: ReplyUpdateInputArgs } = require('./types/reply');
const {
    
     TicketCreate, TicketUpdate } = require('./mutations/ticket');
const {
    
     ReplyCreate, ReplyUpdate } = require('./mutations/reply');

module.exports = new GraphQLObjectType({
    
    
  name: 'Mutation',
  description: 'Root mutation object',
  fields: {
    
    
    createTicket: {
    
    
      type: TicketType,
      args: TicketInputArgs,
      resolve: (_, {
     
      input }, ctx) => {
    
    
        const {
    
     uid } = ctx.session;
        return TicketCreate(uid, input);
      }
    },
    updateTicket: {
    
    
      type: TicketType,
      args: TicketUpdateInputArgs,
      resolve: (_, {
     
      input }, ctx) => {
    
    
        const {
    
     uid } = ctx.session;
        const {
    
     tid, ...args } = input;
        return TicketUpdate(tid, args, uid);
      }
    },
    createReply: {
    
    
      type: ReplyType,
      args: ReplyInputArgs,
      resolve: (_, {
     
      input }, ctx) => {
    
    
        const {
    
     uid } = ctx.session;
        return ReplyCreate(uid, input);
      }
    },
    updateReply: {
    
    
      type: ReplyType,
      args: ReplyUpdateInputArgs,
      resolve: (_, {
     
      input }, ctx) => {
    
    
        const {
    
     uid } = ctx.session;
        return ReplyUpdate(uid, input);
      }
    }
  }
});

In Mutation, there is no need to verify the user's UID, because the verification of the Session is in front.

DataLoader import query

DataLoader Chinese document translation: https://dataloader.js.cool/

const DataLoader = require("dataloader");
const {
    
     query, format } = require("../db");
const {
    
     CountLoader } = require("./connection");

const TICKETTABLE = "xibang.d_ticket";

/**
 * TicketLoader
 * ref: UserLoader
 */
exports.TicketLoader = new DataLoader((tids) => {
    
    
  const sql = format("SELECT * FROM ?? WHERE tid in (?)", [TICKETTABLE, tids]);
  return query(sql).then((rows) =>
    tids.map(
      (tid) =>
        rows.find((row) => row.tid === tid) ||
        new Error(`Row not found: ${
      
      tid}`)
    )
  );
});

/**
 * TicketsLoader
 * Each arg:
 * {  time: {before, after}, // Int, Int
 *    where, // obj: {1:1, type:'xxx'}
 *    order, // 'DESC' / 'ASC'
 *    limit // Int
 * }
 */
exports.TicketsLoader = new DataLoader((args) => {
    
    
  const result = args.map(
    ({
     
      time: {
     
      before, after }, where, order, limit }) => {
    
    
      let time = [];
      if (before) {
    
    
        time.push(format("createdAt > ?", [before]));
      }
      if (after) {
    
    
        time.push(format("createdAt < ?", [after]));
      }
      if (time.length > 0) {
    
    
        time = ` AND ${
      
      time.join(" AND ")}`;
      } else {
    
    
        time = "";
      }
      let sql;
      if (where) {
    
    
        sql = format(
          `SELECT * from ?? WHERE ?${
      
      time} ORDER BY createdAt ${
      
      order} LIMIT ?`,
          [TICKETTABLE, where, limit]
        );
      } else {
    
    
        sql = format(
          `SELECT * from ?? WHERE 1=1${
      
      time} ORDER BY createdAt ${
      
      order} LIMIT ?`,
          [TICKETTABLE, limit]
        );
      }
      return query(sql);
    }
  );
  return Promise.all(result);
});

/**
 * TicketsCountLoader
 * @param {obj} where where args
 * @return {DataLoader} CountLoader
 */
exports.TicketsCounter = (where) => CountLoader.load([TICKETTABLE, where]);

Facebook's Dataloader framework can help reduce the number of queries in the code and improve query efficiency.

GraphQL Edge pagination implementation

Use Cursorpaging, because MySQL does not support Cursor cursors, so it is implemented through code.

const {
    
     parseArgs, fromConnectionCursor, toConnectionCursor } = require('../lib');
const {
    
     TicketsLoader } = require('./ticket');
const {
    
     RepliesLoader } = require('./reply');

/**
 * Switch DataLoader by Type
 * @param {string} type Ticket or TicketReply
 * @returns {function} DataLoader
 */
const TypeLoader = (type) => {
    
    
  if (type === 'Ticket') {
    
    
    return TicketsLoader;
  }
  return RepliesLoader;
};

/**
 * Filter Limit Args
 * @param {string} arg first or last
 * @param {int} v value
 * @returns {int} limit or undefined
 */
const filterLimitArg = (arg, v) => {
    
    
  if (typeof v === 'number') {
    
    
    if (v < 0) {
    
    
      throw new Error(`Argument "${
      
      arg}" must be a non-negative integer`);
    } else if (v > 1000) {
    
    
      return 1000;
    }
    return v;
  }
  return undefined;
};

/**
 * Connection Edges Loader
 * @param {string} type Type Name
 * @param {obj} args Args like: {first: 10, after: "xxx"}
 * @param {int} totalCount totalCount
 * @param {obj} obj parent node object
 * @returns {Promise} {edges, pageInfo: {startCursor, endCursor, hasNextPage, hasPreviousPage}}
 */
exports.NodesLoader = (type, args, totalCount, obj = {
     
     }) => {
    
    
  // 分页查询 limit 字段
  let {
    
     first, last } = args;
  first = filterLimitArg('first', first);
  last = filterLimitArg('last', last);
  const [limit, order] = last === undefined ? [first, 'DESC'] : [last, 'ASC'];

  // 删除查询参数中的 first, last, before, after 无关条件
  // 保留剩余的,如 { type: 'issue' }
  const {
    
     after, before } = args;
  let where = parseArgs(args);
  if (type === 'Ticket') {
    
    
    if (obj.uid) {
    
    
      where.uid = obj.uid;
    }
  } else {
    
    
    where = {
    
    
      tid: obj.tid
    };
  }

  // 从 before, after 中获取 createdAt 和 index
  const [beforeTime, beforeIndex = totalCount] = fromConnectionCursor(before);
  const [afterTime, afterIndex = -1] = fromConnectionCursor(after);

  const loader = TypeLoader(type);
  return loader.load({
    
    
    time: {
    
    
      before: beforeTime,
      after: afterTime
    },
    where,
    order,
    limit
  }).then((nodes) => {
    
    
    const edges = nodes.map((v, i) => ({
    
    
      cursor: toConnectionCursor(v.createdAt, order === 'DESC' ? (afterIndex + i + 1) : (totalCount - beforeIndex - i - 1)),
      node: v
    }));
    const firstEdge = edges[0];
    const lastEdge = edges[edges.length - 1];

    return {
    
    
      edges,
      totalCount,
      pageInfo: {
    
    
        startCursor: firstEdge ? firstEdge.cursor : null,
        endCursor: lastEdge ? lastEdge.cursor : null,
        hasPreviousPage:
          typeof last === 'number' ? (totalCount - beforeIndex - limit) > 0 : false,
        hasNextPage:
          typeof first === 'number' ? (afterIndex + limit) < totalCount : false
      }
    };
  });
};

One caveat: the cursor is base64encoded.

OAuth authentication

const {
    
     getAccessToken } = require('./model');

const e403 = (ctx) => {
    
    
  // 失败
  ctx.status = 403;
  ctx.body = {
    
    
    data: {
    
    },
    errors: [{
    
    
      message: 'You need signin first.',
      type: 'FORBIDDEN'
    }]
  };
};

module.exports = () => (ctx, next) => {
    
    
  const {
    
     access_token: accessTokenQuery = '' } = ctx.query;
  const {
    
     authorization = '' } = ctx.header;
  const accessToken = authorization.startsWith('Bearer ') ? authorization.replace('Bearer ', '') : accessTokenQuery;

  if (accessToken === '') {
    
    
    return e403(ctx);
  }
  // 检查 Token 合法性
  return getAccessToken(accessToken)
    .then((data) => {
    
    
      if (!data) {
    
    
        return e403(ctx);
      }
      ctx.session = data.user;
      return next();
    });
};

This part is relatively simple, and authentication information can be passed through Query or Header.


The complete implementation code download of this project: https://download.csdn.net/download/jslygwx/88188235

Guess you like

Origin blog.csdn.net/jslygwx/article/details/132162000