diff --git a/.vscode/launch.json b/.vscode/launch.json index 370882e..cbae541 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,7 +15,20 @@ "skipFiles": [ "/**" ], - "type": "pwa-node" + "type": "node" + }, + { + "name": "Debug Admin", + "request": "launch", + "runtimeArgs": [ + "run-script", + "dev:admin" + ], + "runtimeExecutable": "npm", + "skipFiles": [ + "/**" + ], + "type": "node" }, ] } \ No newline at end of file diff --git a/docs/uaw.md b/docs/uaw.md index 4c55c8e..1b18579 100644 --- a/docs/uaw.md +++ b/docs/uaw.md @@ -15,6 +15,15 @@ } ``` +### 0. 更新记录 + +#### 20240407 +1. 增加探索预处理接口(24), 用于探索信息上链前创建一条探索记录 +1. 修改 探索(13) request和response结构 +1. 探索状态(12) 增加返回已探索总次数totalUsed +1. 探索状态(12), 移除signCfg, 增加seqStat, 用于标识连续签到奖励领取状态 +1. 增加领取连续签到奖励(25)的接口 + ### 1. 钱包预登录 #### Request @@ -367,6 +376,7 @@ query param ```js { ticket: 1, // 可用探索次数 + totalUsed: 1, // 已探索总次数 todayStat: 0, // 当日签到状态, 0: 未签到, 1: 已签到,但未领取 9:已签到, 已领取 todayTickets: 1, // 当日签到可领取次数 daysTotal: 1, // 累计签到天数 @@ -376,9 +386,10 @@ query param tickets: 2, // 满足条件后可领取数量 state: 0, // 领取状态: 0: 未领取, 1: 可领取, 9: 已领取 }], - signCfg: [{ // 连续签到配置 - days: 2, //天数 - tickets: 1, //额外增加的次数 + seqStat: [{ // 连续签到状态 + days: 3, // 天数 + tickets: 2, // 满足条件后可领取数量 + state: 0, // 领取状态: 0: 未领取, 1: 可领取, 9: 已领取 }] } ``` @@ -396,7 +407,7 @@ body: ```js { - "step": 2 // 使用的次数 + "id": "" // 探索预处理接口返回的id } ``` @@ -635,4 +646,61 @@ body: { ticket: 1 // 获得的探索次数 } -``` \ No newline at end of file +``` + + +### 24.\* 探索预处理 + +#### Request + +- URL:`/api/game/pre_step` +- 方法:`POST` +- 头部: + - Authorization: Bearer JWT_token + +body: + +```js +{ + "step": 2 // 使用的次数 +} + +``` + +#### Response + +```js +{ + id: '' // 本次探索上链需要的数据 +} +``` + + + +### 25.\* 领取连续签到奖励 + +#### Request + +- URL:`/api/user/checkin/claim_seq` +- 方法:`POST` +- 头部: + - Authorization: Bearer JWT_token + +body: + +```js +{ + "days": 3 // 领取的累计签到天数 +} + +``` + +#### Response + +```js +{ + ticket: 2, //获得探索次数 +} +``` + +### \ No newline at end of file diff --git a/package.json b/package.json index c336cfd..22d887f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "build": "tsc -p tsconfig.json", "dev:api": "ts-node -r tsconfig-paths/register src/api.ts", "prod:api": "node dist/api.js", + "dev:admin": "ts-node -r tsconfig-paths/register src/admin.ts", + "prod:admin": "node dist/admin.js", "lint": "eslint --ext .ts src/**", "format": "eslint --ext .ts src/** --fix", "initdata": "ts-node src/initdata.ts" diff --git a/src/controllers/game.controller.ts b/src/controllers/game.controller.ts index 878b801..b4de334 100644 --- a/src/controllers/game.controller.ts +++ b/src/controllers/game.controller.ts @@ -1,6 +1,6 @@ import { MANUAL_OPEN_GAME, RESET_STEP, SCORE_GAME_STEP } from 'common/Constants' import { ActivityGame } from 'models/ActivityGame' -import { DAILY_SIGN, SIGN_TOTAL, TicketRecord, USE_TICKET } from 'models/TicketRecord' +import { DAILY_SIGN, SIGN_SEQ, SIGN_TOTAL, TicketRecord, USE_TICKET } from 'models/TicketRecord' import { queryCheckInList } from 'services/chain.svr' import { checkInToday, seqSignCfg, seqSignScore, totalSignCfg, totalSignScore } from 'services/sign.svr' import { ZError, SyncLocker, BaseController, router } from 'zutils' @@ -8,6 +8,9 @@ import { formatDate } from 'zutils/utils/date.util' import { generateChestLevel, generateNewChest, generateStepReward } from 'services/game.svr' import { ChestStatusEnum } from 'models/ActivityChest' import { updateRankScore } from 'services/rank.svr' +import { ExploreRecord } from 'models/ExploreRecord' +import { isObjectId } from 'zutils/utils/string.util' +import { GeneralScription } from 'models/chain/GeneralScription' /** * 探索游戏相关接口 @@ -116,6 +119,57 @@ class GameController extends BaseController { await ticketRecord.save() return { ticket: score } } + /** + * 领取连续签到奖励 + */ + @router('post /api/user/checkin/claim_seq') + async claimCheckSeqResult(req) { + new SyncLocker().checkLock(req) + const user = req.user + let { days } = req.params + if (!days || isNaN(days)) { + throw new ZError(11, 'invalid days') + } + days = parseInt(days) + if (days < 1) { + throw new ZError(12, 'invalid days') + } + const dateTag = formatDate(new Date()) + const checkRecord = await checkInToday(user.address.toLowerCase(), dateTag) + if (!checkRecord) { + throw new ZError(12, 'not signed in') + } + if (days > checkRecord.total) { + throw new ZError(13, 'invalid days') + } + const ticketRecords = await TicketRecord.find({ user: user.id, activity: user.activity, type: SIGN_TOTAL }) + const claimedSet = new Set() + ticketRecords.forEach(record => { + claimedSet.add(record.data.day) + }) + if (claimedSet.has(days)) { + throw new ZError(14, 'already claimed') + } + + const score = totalSignScore(days) + if (score === 0) { + throw new ZError(15, 'invalid days') + } + const ticketRecord = new TicketRecord({ + user: user.id, + activity: user.activity, + type: SIGN_SEQ, + data: { day: days }, + score, + }) + const gameRecord = await ActivityGame.insertOrUpdate({ user: user.id, activity: user.activity }, {}) + if (MANUAL_OPEN_GAME && gameRecord.status === 0) { + throw new ZError(12, 'map not open') + } + await ActivityGame.updateOne({ user: user.id, activity: user.activity }, { $inc: { tickets: score } }) + await ticketRecord.save() + return { ticket: score } + } /** * 开启地图 */ @@ -157,10 +211,22 @@ class GameController extends BaseController { todayStat = 2 } const scoreBonus = seqSignScore(checkRecord?.count || 0) - const ticketRecords = await TicketRecord.find({ user: user.id, activity: user.activity, type: SIGN_TOTAL }) + const ticketRecords = await TicketRecord.find({ + user: user.id, + activity: user.activity, + type: { $in: [SIGN_TOTAL, USE_TICKET, SIGN_SEQ] }, + }) const claimedSet = new Set() + const seqSet = new Set() + let totalUsed = 0 ticketRecords.forEach(record => { - claimedSet.add(record.data.day) + if (record.type === USE_TICKET) { + totalUsed += record.score + } else if (record.type === SIGN_TOTAL) { + claimedSet.add(record.data.day) + } else if (record.type === SIGN_SEQ) { + seqSet.add(record.data.day) + } }) const totalStat = [] @@ -178,17 +244,66 @@ class GameController extends BaseController { state, }) } + // 连续签到, 根据最大连续签到天数来判断是否拥有领取的资格 + const seqStat = [] + for (let cfg of seqSignCfg) { + let state = 0 + if (cfg.days <= checkRecord?.maxSeq || 0) { + state = 1 + } + if (seqSet.has(cfg.days)) { + state = 9 + } + seqStat.push({ + days: cfg.days, + tickets: cfg.reward, + state, + }) + } totalStat.sort((a, b) => a.days - b.days) return { ticket: record.tickets, + totalUsed: Math.abs(totalUsed), signCfg, todayStat, todayTickets: 1 + scoreBonus, daysTotal: checkRecord?.total || 0, daysSeq: checkRecord?.count || 0, + seqStat, totalStat, } } + @router('get /api/game/pre_step') + async gameChest(req) { + const user = req.user + new SyncLocker().checkLock(req) + let { step } = req.params + step = step || '1' + if (isNaN(step)) { + throw new ZError(11, 'invalid step') + } + + // check if step is safe int + step = parseInt(step) + if (step < 1) { + step = 1 + } + const record = await ActivityGame.insertOrUpdate({ user: user.id, activity: user.activity }, {}) + if (MANUAL_OPEN_GAME && record.status === 0) { + throw new ZError(12, 'map not open') + } + if (record.tickets < step) { + throw new ZError(13, 'insufficient tickets') + } + const exploreRecord = new ExploreRecord({ + user: user.id, + activity: user.activity, + step, + }) + await exploreRecord.save() + + return { id: exploreRecord.id } + } /** * 探索 */ @@ -196,26 +311,36 @@ class GameController extends BaseController { async gameStep(req, res) { new SyncLocker().checkLock(req) const user = req.user - let { step } = req.params - step = step || '1' - if (isNaN(step)) { - throw new ZError(11, 'invalid step') + const { id } = req.params + if (!id) { + throw new ZError(11, 'invalid id') } - // check if step is safe int - step = parseInt(step) - if (step < 1) { - step = 1 + if (!isObjectId(id)) { + throw new ZError(12, 'invalid id') } - // const session = await mongoose.startSession() - // session.startTransaction() - // try { + const chainRecord = await GeneralScription.findOne({ from: user.address.toLowerCase(), op: 'explore', data: id }) + if (!chainRecord) { + throw new ZError(13, 'waiting for chain confirm') + } + const exploreRecord = await ExploreRecord.findById(id) + if (!exploreRecord) { + throw new ZError(14, 'invalid id') + } + if (exploreRecord.status !== 0) { + throw new ZError(15, 'invalid status') + } + const step = exploreRecord.step const record = await ActivityGame.insertOrUpdate({ user: user.id, activity: user.activity }, {}) if (MANUAL_OPEN_GAME && record.status === 0) { - throw new ZError(12, 'map not open') + throw new ZError(16, 'map not open') } if (record.tickets < step) { - throw new ZError(13, 'insufficient tickets') + exploreRecord.status = -1 + await exploreRecord.save() + throw new ZError(17, 'insufficient tickets') } + exploreRecord.status = 1 + await exploreRecord.save() const ticketRecord = new TicketRecord({ user: user.id, activity: user.activity, @@ -253,14 +378,7 @@ class GameController extends BaseController { await chest.save() } await ticketRecord.save() - // await ticketRecord.save({ session }) - // await session.commitTransaction() - // session.endSession() + return { score, chests: chests.map(chest => chest.toJson()) } - // } catch (e) { - // session.abortTransaction() - // session.endSession() - // throw e - // } } } diff --git a/src/models/ExploreRecord.ts b/src/models/ExploreRecord.ts new file mode 100644 index 0000000..bc595be --- /dev/null +++ b/src/models/ExploreRecord.ts @@ -0,0 +1,37 @@ +import { getModelForClass, index, modelOptions, prop } from '@typegoose/typegoose' +import { dbconn } from 'decorators/dbconn' +import { BaseModule } from './Base' + +@dbconn() +@index({ user: 1, activity: 1 }, { unique: false }) +@modelOptions({ + schemaOptions: { collection: 'explore_record', timestamps: true }, +}) +class ExploreRecordClass extends BaseModule { + @prop({ required: true }) + public user: string + + @prop({ required: true }) + public activity: string + + @prop() + public step: number + /** + * 0: 未完成 + * 1: 已完成 + * -1: 无效 + */ + @prop({ default: 0 }) + public status: number + + public toJson() { + return { + user: this.user, + activity: this.activity, + step: this.step, + status: this.status, + } + } +} + +export const ExploreRecord = getModelForClass(ExploreRecordClass, { existingConnection: ExploreRecordClass['db'] }) diff --git a/src/models/TicketRecord.ts b/src/models/TicketRecord.ts index bac0552..baeeee9 100644 --- a/src/models/TicketRecord.ts +++ b/src/models/TicketRecord.ts @@ -5,6 +5,8 @@ import { BaseModule } from './Base' export const DAILY_SIGN = 'daily_sign' // 累计签到奖励 export const SIGN_TOTAL = 'sign_total' +// 连续签到奖励 +export const SIGN_SEQ = 'sign_seq' // 使用门票 export const USE_TICKET = 'use_ticket' diff --git a/src/models/chain/CheckIn.ts b/src/models/chain/CheckIn.ts index 3d67a7e..b7bfebe 100644 --- a/src/models/chain/CheckIn.ts +++ b/src/models/chain/CheckIn.ts @@ -28,6 +28,9 @@ export class CheckInClass extends BaseModule { // 连签天数 @prop({ default: 1 }) public count: number + // 最大连签天数 + @prop({ default: 1 }) + public maxSeq: number // 累计签到天数 @prop({ default: 1 }) public total: number diff --git a/src/models/chain/GeneralScription.ts b/src/models/chain/GeneralScription.ts new file mode 100644 index 0000000..b96e5f7 --- /dev/null +++ b/src/models/chain/GeneralScription.ts @@ -0,0 +1,66 @@ +import { getModelForClass, index, modelOptions, prop } from '@typegoose/typegoose' +import { dbconn } from 'decorators/dbconn' +import { BaseModule } from '../Base' +import { hexToUtf8 } from 'zutils/utils/string.util' +import logger from 'logger/logger' + +@dbconn('chain') +@index({ from: 1, op: 1 }, { unique: false }) +@index({ from: 1, op: 1, data: 1 }, { unique: true }) +@index({ hash: 1 }, { unique: true }) +@index({ from: 1, blockTime: 1 }, { unique: false }) +@modelOptions({ + schemaOptions: { collection: 'general_scription_record', timestamps: true }, +}) +export class GeneralScriptionClass extends BaseModule { + @prop({ required: true }) + public from!: string + @prop() + public to: string + @prop({ required: true }) + public hash: string + @prop() + public blockNumber: string + @prop() + public blockHash: string + @prop() + public blockTime: number + @prop() + public dateTag: string + @prop() + public data: string + @prop() + public op: string + @prop() + public value: string + @prop() + public input: string + @prop({ default: 0 }) + public stat: number + + public static async saveEvent(event: any) { + const dataStr = hexToUtf8(event.input) + const regexp = /data:,{\"p\":\"cf-20\",\"op\":\"(.+?)\",\"val\":\"(.+?)\"}/ + const match = dataStr.match(regexp) + if (!match) { + logger.log('not a general scription:', event.hash) + return + } + event.op = match[1] + event.data = match[2] + logger.log('general scription with op:', event.op, ' data:', event.data) + return GeneralScription.insertOrUpdate({ hash: event.hash }, event) + } + + public toJson() { + return { + address: this.from, + day: this.dateTag, + time: this.blockTime, + } + } +} + +export const GeneralScription = getModelForClass(GeneralScriptionClass, { + existingConnection: GeneralScriptionClass['db'], +})