设计一个基于 GraphQL 的 Node.js 工单系统

news2025/10/21 3:22:19

目录

    • 表结构
    • GraphQL Schema
    • 权限设置
    • 代码实现
      • Query 部分
      • Mutation 部分
      • DataLoader 引入查询
      • GraphQL Edge 分页实现
      • OAuth 鉴权

  • MySQL 数据库存储,Redis 缓存
  • OAuth 鉴权
  • Dataloader 数据查询优化
  • GraphQL 底层接口数据引擎

表结构

数据库采用 MySQL,核心两张表,分别是 工单回复

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;

工单状态分两种:

  • 打开
  • 关闭

回复状态也分两种:

  • 0: 未回复
  • 1: 回复
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

打印脚本:

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

console.log(printSchema(schema));

完整的 GraphQL 结构:

"""
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
}

权限设置

Query 部分:

  • Viewer
    • 用户查询自己的信息
    • 查询自己的工单
  • Ticket
    • 管理员查询所有工单
    • 用户查询自己的工单
    • 查询工单回复
  • Tickets
    • 用户无权限,仅限管理员查询所有工单

Mutation 部分:

  • 创建工单
  • 更新工单:用户操作自己的,管理员操作(关闭、重新打开)所有
  • 添加回复
  • 更新回复
{
  Query: {
    viewer: {
      // 用户(管理员)查询自己的
      tickets: {
        // 用户查询自己的工单
      }
    },
    ticket: {
      // 用户查询自己的,管理员查询所有
      replies: {

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

代码实现

在 Root 中进行鉴权。

Query 部分

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)
  }
});

权限的校验在此处进行。可以通过用户 uid 判断是否为自己的工单,也可以在此处去做管理员的校验。

Mutation 部分

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);
      }
    }
  }
});

Mutation 中不需要进行用户的 UID 校验了,因为有 Session 的校验在前面了。

DataLoader 引入查询

DataLoader 中文文档翻译: 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 的 Dataloader 框架可以帮助代码中减少查询次数,提升查询的效率。

GraphQL Edge 分页实现

使用 Cursor 分页,由于 MySQL 不支持 Cursor 游标,所以通过代码来实现。

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
      }
    };
  });
};

需要注意一下:cursor 是 base64 编码的。

OAuth 鉴权

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();
    });
};

这部分比较简单,可以通过 Query 或者 Header 传递鉴权信息。


该项目完整实现代码下载: https://download.csdn.net/download/jslygwx/88188235

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/850314.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

机器学习深度学习——循环神经网络RNN

&#x1f468;‍&#x1f393;作者简介&#xff1a;一位即将上大四&#xff0c;正专攻机器学习的保研er &#x1f30c;上期文章&#xff1a;机器学习&&深度学习—语言模型和数据集 &#x1f4da;订阅专栏&#xff1a;机器学习&&深度学习 希望文章对你们有所帮助…

JDBC处理批量数据提高效率

文章目录 0 说明1 如何使用jdbc操作数据库1.1 加载数据库驱动1.2 建立数据库连接1.3 创建Statement或者PreparedStatement用来执行SQL1.4 开始执行SQL语句1.5 处理结果集1.6 关闭连接1.7 完整代码 2 批量操作数据库3 如何打印SQL语句4 jdbc常用开源类库5 获取自增id6 获取数据源…

【CSS】网格布局(简单布局、网格合并、网格嵌套)

文章目录 CSS网格布局&#xff08;Grid Layout&#xff09;1. 简单布局2. 网格合并3. 网格嵌套4. 总结 CSS网格布局&#xff08;Grid Layout&#xff09; CSS网格布局&#xff08;Grid Layout&#xff09;是一种强大且灵活的CSS布局系统&#xff0c;允许开发者以网格形式组织和…

快乐的马里奥(广搜入门)

题面 题目描述 马里奥是一个快乐的油漆工人&#xff0c;这天他接到了一个油漆任务&#xff0c;要求马里奥把一个 n 行 m 列的矩阵每一格都用油漆标记一个数字&#xff0c;标记的顺序按照广度优先搜索的方式进行&#xff0c;也就是他会按照如下方式标记&#xff1a; 1、首先标记…

基于springboot+vue的房屋租赁系统(前后端分离)

博主主页&#xff1a;猫头鹰源码 博主简介&#xff1a;Java领域优质创作者、CSDN博客专家、公司架构师、全网粉丝5万、专注Java技术领域和毕业设计项目实战 主要内容&#xff1a;毕业设计(Javaweb项目|小程序等)、简历模板、学习资料、面试题库、技术咨询 文末联系获取 项目介绍…

云原生之使用Docker部署homarr个人导航页

云原生之使用Docker部署homarr个人导航页 一、homarr介绍1.1 homarr简介1.2 homer特点 二、本地环境介绍2.1 本地环境规划2.2 本次实践介绍 三、本地环境检查3.1 检查Docker服务状态3.2 检查Docker版本3.3 检查docker compose 版本 四、下载homarr镜像五、部署homarr导航页5.1 …

彩虹云商城搭建完整教程 完整的学习资料

彩虹云商城搭建完整教程 完整的学习资料提供给大家学习 随着电子商务的快速发展&#xff0c;越来越多的企业开始意识到开设一个自己的电子商城对于销售和品牌推广的重要性。然而&#xff0c;选择一家合适的网站搭建平台和正确地构建一个商城网站并不是一件容易的事情。本文将为…

塔矢行洋对战藤原佐为,谁才是最接近神之一手的人

大家好, 我是嘉宾, 今天我们来盘点一下古今第一高手对局 &#xff0c;塔矢行洋对战藤原佐为&#xff0c;谁才是最接近神之一手的人&#xff0c; 在所有设定都点击好之后, 塔矢行洋下出了自己的第一步 添加图片注释&#xff0c;不超过 140 字&#xff08;可选&#xff09; 佐…

C语言内嵌汇编

反编译&#xff08;二进制文件或者so库&#xff09; objdump --help objdump -M intel -j .text -ld -C -S out > out.txt #显示源代码同时显示行号, 代码段反汇编-M intel 英特尔语法-M x86-64-C:将C符号名逆向解析-S 反汇编的同时&#xff0c;将反汇编代码和源代码交替显…

C++利用mutex和thread实现一个死锁

程序 #include<iostream> #include<mutex> #include<thread> using namespace std; mutex mtx1; mutex mtx2; void A(){mtx1.lock();cout<<"a:mtx1"<<endl;this_thread::sleep_for(chrono::milliseconds(1000));mtx2.lock();cout<…

《Java-SE-第三十一章》之网络编程

前言 在你立足处深挖下去,就会有泉水涌出!别管蒙昧者们叫嚷:“下边永远是地狱!” 博客主页&#xff1a;KC老衲爱尼姑的博客主页 博主的github&#xff0c;平常所写代码皆在于此 共勉&#xff1a;talk is cheap, show me the code 作者是爪哇岛的新手&#xff0c;水平很有限&…

右值引用与移动语义与完美转发

右值引用 右值 什么是右值&#xff0c;没有地址临时数据的我们称之为右值 我们无法对10、aa、字符串取地址的值我们称之为右值。因为他们是临时数据&#xff0c;并不保存再内存中&#xff0c;所以我们右值没有地址&#xff0c;也无法被赋值&#xff08;除const外&#xff0c;左…

【Elasticsearch】学好Elasticsearch系列-分词器

本文已收录至Github&#xff0c;推荐阅读 &#x1f449; Java随想录 先看后赞&#xff0c;养成习惯。 点赞收藏&#xff0c;人生辉煌。 文章目录 规范化&#xff1a;normalization字符过滤器&#xff1a;character filterHTML Strip Character FilterMapping Character FilterP…

IMV6.0

一、背景 经历了多个版本&#xff0c;基础内容在前面&#xff0c;可以使用之前的基础环境&#xff1a; v1&#xff1a; https://blog.csdn.net/wtt234/article/details/132139454 v2&#xff1a; https://blog.csdn.net/wtt234/article/details/132144907 v3&#xff1a; https…

vue实现pdf预览功能

背景&#xff1a;材料上传之后点击预览实现在浏览器上预览的效果 效果如下&#xff1a; 实现代码如下&#xff1a; //预览和下载操作 <el-table-column fixed"right" label"操作" width"210"><template #default"scope">…

JAVA Android 正则表达式

正则表达式 正则表达式是对字符串执行模式匹配的技术。 正则表达式匹配流程 private void RegTheory() {// 正则表达式String content "1998年12月8日&#xff0c;第二代Java平台的企业版J2EE发布。1999年6月&#xff0c;Sun公司发布了第二代Java平台(简称为Java2) &qu…

每次执行@Test方法前都执行一次DB初始化(SpringBoot Test + JUnit5环境)

引言 在执行单元测试时&#xff0c;可以使用诸如H2内存数据库替代线上的Mysql数据库等&#xff0c;如此在执行单元测试时就能尽可能模拟真实环境的SQL执行&#xff0c;同时也无需依赖线上数据库&#xff0c;增加了测试用例执行环境的可移植性。而使用H2数据库时&#xff0c;通…

Node.js |(二)Node.js API:fs模块 | 尚硅谷2023版Node.js零基础视频教程

学习视频&#xff1a;尚硅谷2023版Node.js零基础视频教程&#xff0c;nodejs新手到高手 文章目录 &#x1f4da;文件写入&#x1f407;writeFile 异步写入&#x1f407;writeFileSync 同步写入&#x1f407;appendFile / appendFileSync 追加写入&#x1f407;createWriteStrea…

点成分享丨qPCR仪的原理与使用——以Novacyt产品为例

近年来&#xff0c;PCR检测在多种领域发挥着巨大的作用。短时高效和即时监测都成为了PCR仪发展的方向。作为世界领先的制造商之一&#xff0c;Novacyt公司为来自全球多个国家和行业的用户提供了优质的qPCR仪。 MyGo Mini S qPCR仪是一种紧凑型的实时qPCR仪&#xff0c;非常适合…

【算法|双指针系列No.1】leetcode283. 移动零

个人主页&#xff1a;平行线也会相交 欢迎 点赞&#x1f44d; 收藏✨ 留言✉ 加关注&#x1f493;本文由 平行线也会相交 原创 收录于专栏【手撕算法系列专栏】【LeetCode】 &#x1f354;本专栏旨在提高自己算法能力的同时&#xff0c;记录一下自己的学习过程&#xff0c;希望…