diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..370882e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,21 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Api", + "request": "launch", + "runtimeArgs": [ + "run-script", + "dev:api" + ], + "runtimeExecutable": "npm", + "skipFiles": [ + "/**" + ], + "type": "pwa-node" + }, + ] +} \ No newline at end of file diff --git a/src/api.server.ts b/src/api.server.ts index f3a9ab2..930fede 100644 --- a/src/api.server.ts +++ b/src/api.server.ts @@ -34,16 +34,6 @@ export class ApiServer { this.server.register(zReqParserPlugin) this.server.register(helmet, { hidePoweredBy: false }) this.server.register(zTokenParserPlugin) - this.server.register(require('@fastify/view'), { - engine: { - ejs: require('ejs'), - }, - }) - this.server.register(require('@fastify/static'), { - root: path.join(__dirname, '../public'), - prefix: '/public/', // optional: default '/' - constraints: {}, // optional: default {} - }) this.server.register(apiAuthPlugin, { secret: process.env.API_TOKEN_SECRET, diff --git a/src/common/Extend.ts b/src/common/Extend.ts index dfe36b5..0e51ae6 100644 --- a/src/common/Extend.ts +++ b/src/common/Extend.ts @@ -652,6 +652,12 @@ interface Array { * @return {Map} */ toMap?(key: string): Map + + /** + * 将数组分块 + * @param chunkSize + */ + chunkArray?(chunkSize: number): T[][] } Object.defineProperties(Array.prototype, { @@ -940,6 +946,19 @@ Object.defineProperties(Array.prototype, { }, writable: true, }, + chunkArray: { + value: function (this: T[], chunkSize: number): T[][] { + const chunks: T[][] = [] + + for (let i = 0; i < this.length; i += chunkSize) { + const chunk = this.slice(i, i + chunkSize) + chunks.push(chunk) + } + + return chunks + }, + writable: true, + }, }) interface Map { diff --git a/src/controllers/account.controller.ts b/src/controllers/account.controller.ts index a68d525..a8bf0ae 100644 --- a/src/controllers/account.controller.ts +++ b/src/controllers/account.controller.ts @@ -49,33 +49,10 @@ export default class AccountController extends BaseController { accountData.lastLogin = Date.now() await accountData.save() - const token = await res.jwtSign({ id: accountData.id }) + const token = await res.jwtSign({ id: accountData.id, address: accountData.address }) setImmediate(() => { NonceRecord.removeExpired() }) return { token } } - - @router('get /api/user/status') - async info(req) { - let user = req.user - return {} - } - - @router('post /api/user/claim') - async claim(req, res) { - let user = req.user - logger.db('claim', req) - const {} = req.params - const taskId = '' - return { taskId } - } - - @router('get /api/user/claim/:taskId') - async claimStatus(req, res) { - const { taskId } = req.params - const txHashList = [] - let status = 0 - return { status, txHashList } - } } diff --git a/src/controllers/chain.controller.ts b/src/controllers/chain.controller.ts new file mode 100644 index 0000000..80ff2de --- /dev/null +++ b/src/controllers/chain.controller.ts @@ -0,0 +1,78 @@ +import { router } from 'decorators/router' +import BaseController, { ROLE_ANON } from 'common/base.controller' +import { ZError } from 'common/ZError' +import logger from 'logger/logger' +import { getMintableCount } from 'service/ChainSvr' +import { ClaimTask } from 'models/ClaimTask' +import { ChainQueue } from 'queue/chain.queue' +import { MINT_CHANNEL, NFT_TYPE } from 'utils/nft.util' + +const NFT_TYPES = { + '1': NFT_TYPE.badge1, + '2': NFT_TYPE.badge2, + '3': NFT_TYPE.badge3, + '4': NFT_TYPE.badge4, +} + +const D_ADDRESS = { + '1': process.env.CHAIN_DISTRIBUTOR_ADDRESS, + '2': process.env.CHAIN_DISTRIBUTOR_ADDRESS, + '3': process.env.CHAIN_DISTRIBUTOR_ADDRESS, + '4': process.env.CHAIN_DISTRIBUTOR_ADDRESS, +} + +export default class ChainController extends BaseController { + @router('get /api/user/status') + async info(req) { + let { address } = req.user + if (!address) { + throw new ZError(10, 'user address not found') + } + let count = await getMintableCount(address) + let result = { + '1': !!count ? 1 : 0, + '2': 0, + '3': 0, + '4': 0, + } + return result + } + + @router('post /api/user/claim') + async claim(req, res) { + let user = req.user + logger.db('claim', req) + let { id } = req.params + id = id || '1' + if (isNaN(id)) { + throw new ZError(10, 'id must be number') + } + let idNum = parseInt(id) + if (idNum > 4 || idNum < 1) { + throw new ZError(11, 'id can not > 4 and < 1') + } + let record = await ClaimTask.insertOrUpdate( + { uid: user.id, type: id }, + { + address: user.address, + $inc: { version: 1 }, + nftType: NFT_TYPES[id], + channel: MINT_CHANNEL.claim, + dAddress: D_ADDRESS[id], + }, + ) + const taskId = record.id + if (record.version === 1) { + new ChainQueue().addTask(record) + } + return { taskId } + } + + @router('get /api/user/claim/:taskId') + async claimStatus(req, res) { + const { taskId } = req.params + const txHashList = [] + let status = 0 + return { status, txHashList } + } +} diff --git a/src/controllers/internal.controller.ts b/src/controllers/internal.controller.ts new file mode 100644 index 0000000..1eeb3e1 --- /dev/null +++ b/src/controllers/internal.controller.ts @@ -0,0 +1,49 @@ +import { role, router } from 'decorators/router' +import BaseController, { ROLE_ANON } from 'common/base.controller' +import { ZError } from 'common/ZError' +import logger from 'logger/logger' +import { ClaimTask } from 'models/ClaimTask' +import { hmacSha256 } from 'utils/security.util' + +const calcHash = function (data: any) { + return hmacSha256(JSON.stringify(data), process.env.HASH_SALT) +} + +export default class InternalController extends BaseController { + @role(ROLE_ANON) + @router('post /api/internal/update_task') + async updateTaskInfo(req) { + let { sign, taskId, result, successCount, errorCount, hashList } = req.params + if (!sign) { + throw new ZError(10, 'sign not found') + } + let hash = calcHash({ taskId, result, successCount, errorCount, hashList }) + if (sign !== hash) { + throw new ZError(11, 'sign not match') + } + logger.info(`task report:: ${taskId}|${result}|${successCount}|${errorCount}|${JSON.stringify(hashList)}}`) + if (!taskId) { + throw new ZError(11, 'taskId not found') + } + let record = await ClaimTask.findById(taskId) + if (!record) { + throw new ZError(12, 'task not found') + } + switch (result) { + case 2: + record.status = 9 + break + case 8: + record.status = 11 + break + case 9: + record.status = 10 + break + } + record.successCount = successCount + record.errorCount = errorCount + record.hashList = hashList + await record.save() + return {} + } +} diff --git a/src/models/ClaimTask.ts b/src/models/ClaimTask.ts new file mode 100644 index 0000000..4efaee1 --- /dev/null +++ b/src/models/ClaimTask.ts @@ -0,0 +1,58 @@ +import { getModelForClass, index, modelOptions, pre, prop, ReturnModelType } from '@typegoose/typegoose' +import { dbconn } from 'decorators/dbconn' +import { BaseModule } from './Base' + +@dbconn() +@index({ uid: 1, type: 1 }, { unique: true }) +@modelOptions({ schemaOptions: { collection: 'claim_task', timestamps: true } }) +export class ClaimTaskClass extends BaseModule { + @prop({ required: true }) + public uid: string + + @prop({ required: true }) + public type: string + // nft类型编码 + @prop() + public nftType: string + // 渠道编号 + @prop() + public channel: string + + // 用户的钱包地址 + @prop() + public address: string + + @prop() + public dAddress: string + + @prop({ default: 0 }) + public status: number + + @prop({ default: 0 }) + public totalCount: number + + @prop({ default: 0 }) + public successCount: number + + @prop({ default: 0 }) + public errorCount: number + + @prop({ type: () => [String] }) + public nftList: string[] + + @prop({ type: () => [String] }) + public hashList: string[] + + /** + * 0: 队列中, 等待上链 + * 1: 已请求上链, 等待结果 + * 2: 成功上链, 等待确认 + * 9: 已确认成功(成功的最终状态) + * 10: 失败 + * 11: 部分失败 + */ + @prop({ default: 0 }) + public version: number +} + +export const ClaimTask = getModelForClass(ClaimTaskClass, { existingConnection: ClaimTaskClass.db }) diff --git a/src/queue/chain.queue.ts b/src/queue/chain.queue.ts new file mode 100644 index 0000000..2b7b421 --- /dev/null +++ b/src/queue/chain.queue.ts @@ -0,0 +1,79 @@ +import { AsyncQueue, createAsyncQueue } from 'common/AsyncQueue' +import { singleton } from 'decorators/singleton' +import { DocumentType } from '@typegoose/typegoose' +import logger from 'logger/logger' +import { ClaimTaskClass } from 'models/ClaimTask' +import { addTask, getMintableCount } from 'service/ChainSvr' +import { generateNftID } from 'utils/nft.util' + +const MAX_BATCH_COUNT = 20 + +/** + * let data = { + taskId: '1', + type: 2, + data: [ + { + address: '0xd45A464a2412A2f83498d13635698a041b9dBe9b', + to: '0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1', + tokenIds: ['3'], + configIds: ['1'], + }, + ], + } + */ + +@singleton +export class ChainQueue { + private queue: AsyncQueue + + constructor() { + this.queue = createAsyncQueue() + } + + public addTask(task: DocumentType) { + this.queue.push(async () => { + try { + let numRes = await getMintableCount(task.address) + task.countTotal = numRes.data + if (task.countTotal === 0) { + task.status = 10 + await task.save() + return + } + + // datas: [{address: distributor's address, to: userAddress, nftList: []}] + const address = task.dAddress + const to = task.address + let nftList: string[] = [] + for (let i = 0; i < task.countTotal; i++) { + let tokenId = await generateNftID(task.nftType, task.channel) + nftList.push(tokenId) + } + let all = nftList.chunkArray(MAX_BATCH_COUNT) + let datas: any = [] + for (let i = 0; i < all.length; i++) { + datas.push({ + address, + to, + nftList: all[i], + }) + } + + task.nftList = nftList + let reqData = { + taskId: task.id, + type: 5, + data: datas, + } + + await addTask(reqData) + task.status = 1 + await task.save() + } catch (err) { + logger.error('error add chain task: ') + logger.error(err) + } + }) + } +} diff --git a/src/service/ChainSvr.ts b/src/service/ChainSvr.ts new file mode 100644 index 0000000..8cfc458 --- /dev/null +++ b/src/service/ChainSvr.ts @@ -0,0 +1,30 @@ +import axios from 'axios' + +const ADD_TASK_URI = '/chain/req' +const USER_INFO_URI = '/chain/query_info' + +export async function addTask(data) { + let url = `${process.env.CHAIN_CLIENT_URL}${ADD_TASK_URI}` + let reqConfig: any = { + method: 'post', + url, + headers: { + 'Content-Type': 'application/json', + }, + data: JSON.stringify(data), + } + return axios(reqConfig) +} + +export async function getMintableCount(address: string) { + let url = `${process.env.CHAIN_CLIENT_URL}${USER_INFO_URI}` + let reqConfig: any = { + method: 'post', + url, + headers: { + 'Content-Type': 'application/json', + }, + data: JSON.stringify({ address }), + } + return axios(reqConfig).then(response => response.data) +} diff --git a/src/utils/nft.util.ts b/src/utils/nft.util.ts new file mode 100644 index 0000000..5f69982 --- /dev/null +++ b/src/utils/nft.util.ts @@ -0,0 +1,39 @@ +import { IDCounter } from 'models/IDCounter' + +export const ONE_DAY = 24 * 60 * 60 * 1000 +export const NFT_BEGIN_DAY = new Date(2023, 4, 8) + +export const NFT_TYPE = { + badge1: 100, //2022NFT购买用户奖励徽章 + badge2: 101, + badge3: 102, + badge4: 103, +} + +export const MINT_CHANNEL = { + claim: '01', // 2022购买用户claim +} + +// calc days between two Date +export function daysBetween(date1: Date, date2: Date) { + // hours*minutes*seconds*milliseconds + const diffInMs = Math.abs(date1.getTime() - date2.getTime()) + const diffInDays = Math.round(diffInMs / ONE_DAY) + return diffInDays +} + +/** + * 生成nft的tokenid + * 规则: + * 100 9999 00 0000001 + * NFT类型 当前日期至开始日期的天数 渠道编号 当前类型的序号 + */ +export async function generateNftID(nfttype: number, channel: number) { + const days = daysBetween(new Date(), NFT_BEGIN_DAY) + const dayKey = (days + '').padStart(4, '0') + const channelKey = (channel + '').padStart(2, '0') + const idkey = nfttype + dayKey + channelKey + const idobj = await IDCounter.nextID(idkey) + const val = (idobj.seq + '').padStart(7, '0') + return nfttype + val +} diff --git a/src/utils/security.util.ts b/src/utils/security.util.ts new file mode 100644 index 0000000..8ddf5a4 --- /dev/null +++ b/src/utils/security.util.ts @@ -0,0 +1,74 @@ +import crypto from 'crypto' + +export function hmac(input, key, out) { + return out + ? crypto.createHmac('sha1', key).update(input).digest(out) + : crypto.createHmac('sha1', key).update(input).digest('hex') +} + +export function genRandomString(length) { + return crypto + .randomBytes(Math.ceil(length / 2)) + .toString('hex') + .slice(0, length) +} + +export function sha512(password, salt) { + let hash = crypto.createHmac('sha512', salt) + hash.update(password) + let value = hash.digest('hex') + return { + salt: salt, + passwordHash: value, + } +} + +export function sha1(str) { + const md5sum = crypto.createHash('sha1') + md5sum.update(str) + str = md5sum.digest('hex') + return str +} + +export function hmacSha256(str: string, key: any) { + const md5sum = crypto.createHmac('sha256', key) + md5sum.update(str) + str = md5sum.digest('hex') + return str +} + +export function md5(str) { + const md5sum = crypto.createHash('md5') + md5sum.update(str) + str = md5sum.digest('hex') + return str +} + +export function createSign(secretKey, paramStr, timestamp) { + paramStr = `${paramStr}:${timestamp}:${secretKey}` + return sha1(paramStr) +} + +export function checkSign({ + secretKey, + data, + sign, + signKeys, +}: { + secretKey: string + data: {} + sign: string + signKeys: string[] +}) { + signKeys.sort() + let signStr = '' + for (let key of signKeys) { + if (signStr.length > 0) { + signStr += '&' + } + signStr += `${key}=${data[key]}` + } + console.log(signStr) + let sign1 = hmacSha256(signStr, secretKey) + return sign1 === sign +}