From 443187e047b48c957d0d64ac910103d16c7ef96a Mon Sep 17 00:00:00 2001 From: ZihanChen821 <130351655+ZihanChen821@users.noreply.github.com> Date: Sun, 4 Feb 2024 20:21:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(mis):=20=E4=BF=AE=E5=A4=8D=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=BB=9F=E8=AE=A1=E6=97=B6=E5=8C=BA=E8=AF=AF=E5=B7=AE?= =?UTF-8?q?=E9=97=AE=E9=A2=98=20(#1094)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 现状 目前数据统计功能在web侧得时间日期筛选,进入到server端及数据库会以utc 时间 来进行搜索,但大部分用户其实是希望以自己所在时区进行日期筛选。 举例: 关于1-17到1-23号的用户活跃数的统计 如果以0时区进行统计: ![img_v3_027c_c7a6c6e5-f096-47dc-85e3-3b79e15fc33g](https://github.com/PKUHPC/SCOW/assets/130351655/6251e5c7-4e96-4372-99d4-c16e4d24fece) 但是数据库里其实 2024-01-22 的记录是八时区的01-23号产生的(2024-01-22 20:56:09.789000),所以正确的结果应该如下(sql将0时区转为8时区后计算) ![img_v3_027c_8ab313d4-fdaf-4799-bcfc-5c17314d1f8g](https://github.com/PKUHPC/SCOW/assets/130351655/14d13d8d-da97-4bfa-b980-06559a42021d) ### 改动 GetDailyCharge,GetDailyPay,GetNewJobCount,GetNewUserCount,GetActiveUserCount 接口增加时区参数,在数据库进行搜索时以传参时区进行转换(没传就默认为UTC时区),客户端侧通过Intl.DateTimeFormat().resolvedOptions().timeZone 获取当前时区。 --- .changeset/happy-dingos-care.md | 5 ++ .changeset/hot-tigers-dream.md | 8 ++ apps/audit-server/package.json | 1 + apps/audit-server/src/services/statistic.ts | 27 +++--- .../tests/statistic/statistic.test.ts | 10 ++- apps/mis-server/src/services/charging.ts | 39 ++++++--- apps/mis-server/src/services/job.ts | 18 ++-- apps/mis-server/src/services/user.ts | 18 ++-- apps/mis-server/tests/admin/user.test.ts | 18 +++- .../tests/charging/statistics.test.ts | 46 +++++++--- apps/mis-server/tests/job/JobService.test.ts | 40 ++++++--- apps/mis-web/src/apis/api.mock.ts | 10 +-- apps/mis-web/src/models/date.ts | 19 +++++ apps/mis-web/src/pages/admin/statistic.tsx | 14 ++- .../src/pages/api/admin/getActiveUserCount.ts | 19 +++-- .../src/pages/api/admin/getDailyCharge.ts | 12 ++- .../src/pages/api/admin/getDailyPay.ts | 12 ++- .../src/pages/api/admin/getNewJobCount.ts | 11 ++- .../src/pages/api/admin/getNewUserCount.ts | 18 ++-- apps/mis-web/src/utils/date.ts | 41 +++++++++ docs/docs/integration/index.md | 2 +- .../scow-api-hook/{ => api}/api.md | 8 +- .../scow-api-hook/api/statistic.md | 58 +++++++++++++ libs/server/package.json | 1 + libs/server/src/date.ts | 85 +++++++++++++++++++ libs/server/src/index.ts | 1 + libs/server/tests/date.test.ts | 82 ++++++++++++++++++ pnpm-lock.yaml | 6 ++ protos/audit/statistic.proto | 8 +- protos/google/type/date.proto | 52 ++++++++++++ protos/server/charging.proto | 9 +- protos/server/job.proto | 6 +- protos/server/user.proto | 7 +- 33 files changed, 602 insertions(+), 109 deletions(-) create mode 100644 .changeset/happy-dingos-care.md create mode 100644 .changeset/hot-tigers-dream.md create mode 100644 apps/mis-web/src/models/date.ts create mode 100644 apps/mis-web/src/utils/date.ts rename docs/docs/integration/scow-api-hook/{ => api}/api.md (91%) create mode 100644 docs/docs/integration/scow-api-hook/api/statistic.md create mode 100644 libs/server/src/date.ts create mode 100644 libs/server/tests/date.test.ts create mode 100644 protos/google/type/date.proto diff --git a/.changeset/happy-dingos-care.md b/.changeset/happy-dingos-care.md new file mode 100644 index 0000000000..d959a492d7 --- /dev/null +++ b/.changeset/happy-dingos-care.md @@ -0,0 +1,5 @@ +--- +"@scow/grpc-api": minor +--- + +GetDailyCharge,GetDailyPay,GetNewJobCount,GetNewUserCount,GetActiveUserCount 接口新增 time_zone 参数以及返回类型由时间戳改为 date diff --git a/.changeset/hot-tigers-dream.md b/.changeset/hot-tigers-dream.md new file mode 100644 index 0000000000..6014270d91 --- /dev/null +++ b/.changeset/hot-tigers-dream.md @@ -0,0 +1,8 @@ +--- +"@scow/audit-server": patch +"@scow/mis-server": patch +"@scow/mis-web": patch +"@scow/lib-server": patch +--- + +修复数据统计相关功能时区转换问题 diff --git a/apps/audit-server/package.json b/apps/audit-server/package.json index 00d0f1217a..3f431161ad 100644 --- a/apps/audit-server/package.json +++ b/apps/audit-server/package.json @@ -33,6 +33,7 @@ "@mikro-orm/seeder": "6.0.5", "@scow/config": "workspace:*", "@scow/lib-config": "workspace:*", + "@scow/lib-server": "workspace:*", "@scow/lib-decimal": "workspace:*", "@scow/lib-operation-log": "workspace:*", "@scow/utils": "workspace:*", diff --git a/apps/audit-server/src/services/statistic.ts b/apps/audit-server/src/services/statistic.ts index 5da29b0218..175e827cb8 100644 --- a/apps/audit-server/src/services/statistic.ts +++ b/apps/audit-server/src/services/statistic.ts @@ -13,6 +13,7 @@ import { ensureNotUndefined, plugin } from "@ddadaal/tsgrpc-server"; import { QueryOrder, raw } from "@mikro-orm/core"; import { OperationType } from "@scow/lib-operation-log"; +import { checktTimeZone, convertToDateMessage } from "@scow/lib-server/build/date"; import { StatisticServiceServer, StatisticServiceService } from "@scow/protos/build/audit/statistic"; import { OperationLog } from "src/entities/OperationLog"; @@ -21,24 +22,28 @@ export const statisticServiceServer = plugin((server) => { server.addService(StatisticServiceService, { - getActiveUserCount: async ({ request, em }) => { + getActiveUserCount: async ({ request, em, logger }) => { - const { startTime, endTime } = ensureNotUndefined(request, ["startTime", "endTime"]); + const { startTime, endTime, timeZone = "UTC" } = ensureNotUndefined(request, ["startTime", "endTime"]); - const qb = em.createQueryBuilder(OperationLog, "o"); + checktTimeZone(timeZone); + const qb = em.createQueryBuilder(OperationLog, "o"); qb - .select([raw("DATE(o.operation_time) as date"), raw("COUNT(DISTINCT o.operator_user_id) as userCount")]) + .select([ + raw("DATE(CONVERT_TZ(o.operation_time, 'UTC', ?)) as date", [timeZone]), + raw("COUNT(DISTINCT o.operator_user_id) as userCount"), + ]) .where({ operationTime: { $gte: startTime } }) .andWhere({ operationTime: { $lte: endTime } }) - .groupBy(raw("DATE(o.operation_time)")) - .orderBy({ [raw("DATE(o.operation_time)")]: QueryOrder.DESC }); + .groupBy(raw("date")) + .orderBy({ [raw("date")]: QueryOrder.DESC }); const records: {date: string, userCount: number}[] = await qb.execute(); return [{ results: records.map((record) => ({ - date: record.date, + date: convertToDateMessage(record.date, logger), count: record.userCount, })), }]; @@ -72,8 +77,8 @@ export const statisticServiceServer = plugin((server) => { .where({ operationTime: { $gte: startTime } }) .andWhere({ operationTime: { $lte: endTime } }) .andWhere({ [raw("JSON_EXTRACT(meta_data, '$.$case')")]: { $in: portalOperationType } }) - .groupBy(raw("JSON_EXTRACT(meta_data, '$.$case')")) - .orderBy({ [raw("COUNT(*)")]: QueryOrder.DESC }); + .groupBy(raw("operationType")) + .orderBy({ [raw("count")]: QueryOrder.DESC }); const results: {operationType: string, count: number}[] = await qb.execute(); @@ -126,8 +131,8 @@ export const statisticServiceServer = plugin((server) => { .where({ operationTime: { $gte: startTime } }) .andWhere({ operationTime: { $lte: endTime } }) .andWhere({ [raw("JSON_EXTRACT(meta_data, '$.$case')")]: { $in: misOperationType } }) - .groupBy(raw("JSON_EXTRACT(meta_data, '$.$case')")) - .orderBy({ [raw("COUNT(*)")]: QueryOrder.DESC }); + .groupBy(raw("operationType")) + .orderBy({ [raw("count")]: QueryOrder.DESC }); const results: {operationType: string, count: number}[] = await qb.execute(); diff --git a/apps/audit-server/tests/statistic/statistic.test.ts b/apps/audit-server/tests/statistic/statistic.test.ts index d74baf3ab4..f4b816b9e9 100644 --- a/apps/audit-server/tests/statistic/statistic.test.ts +++ b/apps/audit-server/tests/statistic/statistic.test.ts @@ -13,6 +13,7 @@ import { asyncClientCall } from "@ddadaal/tsgrpc-client"; import { Server } from "@ddadaal/tsgrpc-server"; import { ChannelCredentials } from "@grpc/grpc-js"; +import { dayjsToDateMessage } from "@scow/lib-server/build/date"; import { StatisticServiceClient } from "@scow/protos/build/audit/statistic"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; @@ -43,7 +44,7 @@ afterEach(async () => { await server.close(); }); -it("get active user count correctly", async () => { +it("get active user count correctly in UTC+8 timezone", async () => { const em = server.ext.orm.em.fork(); @@ -65,13 +66,14 @@ it("get active user count correctly", async () => { const resp = await asyncClientCall(client, "getActiveUserCount", { startTime: today.toISOString(), endTime: endDay.toISOString(), + timeZone: "Asia/Shanghai", }); + const nowInUtcPlus8 = now.utcOffset(8); + expect(resp.results).toMatchObject([ { - // 应该期待返回的结果的日期为日志的operationTime的UTC日期的开始时间 - // date: today.toISOString(), - date: now.utcOffset(0).startOf("day").toISOString(), + date: dayjsToDateMessage(nowInUtcPlus8), count: 10, }, ]); diff --git a/apps/mis-server/src/services/charging.ts b/apps/mis-server/src/services/charging.ts index 1545fa1fe0..762360cc45 100644 --- a/apps/mis-server/src/services/charging.ts +++ b/apps/mis-server/src/services/charging.ts @@ -14,6 +14,7 @@ import { ensureNotUndefined, plugin } from "@ddadaal/tsgrpc-server"; import { ServiceError, status } from "@grpc/grpc-js"; import { LockMode, QueryOrder, raw } from "@mikro-orm/core"; import { Decimal, decimalToMoney, moneyToNumber, numberToMoney } from "@scow/lib-decimal"; +import { checktTimeZone, convertToDateMessage } from "@scow/lib-server/build/date"; import { ChargeRecord as ChargeRecordProto, ChargingServiceServer, ChargingServiceService } from "@scow/protos/build/server/charging"; import { charge, pay } from "src/bl/charging"; @@ -303,29 +304,34 @@ export const chargingServiceServer = plugin((server) => { ]; }, - getDailyCharge: async ({ request, em }) => { + getDailyCharge: async ({ request, em, logger }) => { - const { startTime, endTime } = ensureNotUndefined(request, ["startTime", "endTime"]); + const { startTime, endTime, timeZone = "UTC" } = ensureNotUndefined(request, ["startTime", "endTime"]); + + checktTimeZone(timeZone); const qb = em.createQueryBuilder(ChargeRecord, "cr"); qb - .select([raw("DATE(cr.time) as date"), raw("SUM(cr.amount) as totalAmount")]) + .select([ + raw("DATE(CONVERT_TZ(cr.time, 'UTC', ?)) as date", [timeZone]), + raw("SUM(cr.amount) as totalAmount"), + ]) .where({ time: { $gte: startTime } }) .andWhere({ time: { $lte: endTime } }) .andWhere({ accountName: { $ne: null } }) - .groupBy(raw("DATE(cr.time)")) - .orderBy({ [raw("DATE(cr.time)")]: QueryOrder.DESC }); + .groupBy(raw("date")) + .orderBy({ [raw("date")]: QueryOrder.DESC }); const records: {date: string, totalAmount: number}[] = await queryWithCache({ em, - queryKeys: ["get_daily_charge", `${startTime}`, `${endTime}`], + queryKeys: ["get_daily_charge", `${startTime}`, `${endTime}`, `${timeZone}`], queryQb: qb, }); return [{ results: records.map((record) => ({ - date: new Date(record.date).toISOString(), + date: convertToDateMessage(record.date, logger), amount: numberToMoney(record.totalAmount), })), }]; @@ -361,29 +367,34 @@ export const chargingServiceServer = plugin((server) => { ]; }, - getDailyPay: async ({ request, em }) => { + getDailyPay: async ({ request, em, logger }) => { + + const { startTime, endTime, timeZone = "UTC" } = ensureNotUndefined(request, ["startTime", "endTime"]); - const { startTime, endTime } = ensureNotUndefined(request, ["startTime", "endTime"]); + checktTimeZone(timeZone); const qb = em.createQueryBuilder(PayRecord, "pr"); qb - .select([raw("DATE(pr.time) as date"), raw("SUM(pr.amount) as totalAmount")]) + .select([ + raw("DATE(CONVERT_TZ(pr.time, 'UTC', ?)) as date", [timeZone]), + raw("SUM(pr.amount) as totalAmount"), + ]) .where({ time: { $gte: startTime } }) .andWhere({ time: { $lte: endTime } }) .andWhere({ accountName: { $ne: null } }) - .groupBy(raw("DATE(pr.time)")) - .orderBy({ [raw("DATE(pr.time)")]: QueryOrder.DESC }); + .groupBy(raw("date")) + .orderBy({ [raw("date")]: QueryOrder.DESC }); const records: {date: string, totalAmount: number}[] = await queryWithCache({ em, - queryKeys: ["get_daily_charge", `${startTime}`, `${endTime}`], + queryKeys: ["get_daily_pay", `${startTime}`, `${endTime}`, `${timeZone}`], queryQb: qb, }); return [{ results: records.map((record) => ({ - date: new Date(record.date).toISOString(), + date: convertToDateMessage(record.date, logger), amount: numberToMoney(record.totalAmount), })), }]; diff --git a/apps/mis-server/src/services/job.ts b/apps/mis-server/src/services/job.ts index a48a23d8cb..23ede492ff 100644 --- a/apps/mis-server/src/services/job.ts +++ b/apps/mis-server/src/services/job.ts @@ -17,6 +17,7 @@ import { Status } from "@grpc/grpc-js/build/src/constants"; import { FilterQuery, QueryOrder, raw, UniqueConstraintViolationException } from "@mikro-orm/core"; import { Decimal, decimalToMoney, moneyToNumber } from "@scow/lib-decimal"; import { jobInfoToRunningjob } from "@scow/lib-scheduler-adapter"; +import { checktTimeZone, convertToDateMessage } from "@scow/lib-server/build/date"; import { ChargeRecord } from "@scow/protos/build/server/charging"; import { GetJobsResponse, @@ -412,15 +413,19 @@ export const jobServiceServer = plugin((server) => { }, getNewJobCount: async ({ request, em }) => { - const { startTime, endTime } = ensureNotUndefined(request, ["startTime", "endTime"]); + const { startTime, endTime, timeZone = "UTC" } = ensureNotUndefined(request, ["startTime", "endTime"]); + + checktTimeZone(timeZone); const qb = em.createQueryBuilder(JobInfoEntity, "j"); qb - .select([raw("DATE(j.time_submit) as date"), raw("COUNT(*) as count")]) + .select([ + raw("DATE(CONVERT_TZ(j.time_submit, 'UTC', ?)) as date", [timeZone]), + raw("COUNT(*) as count")]) .where({ timeSubmit: { $gte: startTime } }) .andWhere({ timeSubmit: { $lte: endTime } }) - .groupBy(raw("DATE(j.time_submit)")) - .orderBy({ [raw("DATE(j.time_submit)")]: QueryOrder.DESC }); + .groupBy(raw("date")) + .orderBy({ [raw("date")]: QueryOrder.DESC }); const results: {date: string, count: number}[] = await queryWithCache({ em, @@ -429,7 +434,10 @@ export const jobServiceServer = plugin((server) => { }); return [ { - results, + results: results.map((record) => ({ + date: convertToDateMessage(record.date, logger), + count: record.count, + })), }, ]; }, diff --git a/apps/mis-server/src/services/user.ts b/apps/mis-server/src/services/user.ts index 9c4afd4a63..6001ede2b7 100644 --- a/apps/mis-server/src/services/user.ts +++ b/apps/mis-server/src/services/user.ts @@ -19,6 +19,7 @@ import { addUserToAccount, changeEmail as libChangeEmail, createUser, getCapabil } from "@scow/lib-auth"; import { decimalToMoney } from "@scow/lib-decimal"; +import { checktTimeZone, convertToDateMessage } from "@scow/lib-server/build/date"; import { AccountStatus, GetAccountUsersResponse, @@ -762,22 +763,27 @@ export const userServiceServer = plugin((server) => { return [{}]; }, - getNewUserCount: async ({ request, em }) => { - const { startTime, endTime } = ensureNotUndefined(request, ["startTime", "endTime"]); + getNewUserCount: async ({ request, em, logger }) => { + const { startTime, endTime, timeZone = "UTC" } = ensureNotUndefined(request, ["startTime", "endTime"]); + + checktTimeZone(timeZone); const qb = em.createQueryBuilder(User, "u"); qb - .select([raw("DATE(u.create_time) as date"), raw("count(*) as count")]) + .select([raw("DATE(CONVERT_TZ(u.create_time, 'UTC', ?)) as date", [timeZone]), raw("count(*) as count")]) .where({ createTime: { $gte: startTime } }) .andWhere({ createTime: { $lte: endTime } }) - .groupBy(raw("DATE(u.create_time)")) - .orderBy({ [raw("DATE(u.create_time)")]: QueryOrder.DESC }); + .groupBy(raw("date")) + .orderBy({ [raw("date")]: QueryOrder.DESC }); const results: {date: string, count: number}[] = await qb.execute(); return [ { - results, + results: results.map((record) => ({ + date: convertToDateMessage(record.date, logger), + count: record.count, + })), }, ]; }, diff --git a/apps/mis-server/tests/admin/user.test.ts b/apps/mis-server/tests/admin/user.test.ts index b2f4ea2ddd..299ee14bbe 100644 --- a/apps/mis-server/tests/admin/user.test.ts +++ b/apps/mis-server/tests/admin/user.test.ts @@ -16,9 +16,11 @@ import { ChannelCredentials } from "@grpc/grpc-js"; import { Status } from "@grpc/grpc-js/build/src/constants"; import { Loaded } from "@mikro-orm/core"; import { createUser } from "@scow/lib-auth"; +import { dayjsToDateMessage } from "@scow/lib-server/build/date"; import { GetAllUsersRequest_UsersSortField, PlatformRole, platformRoleFromJSON, SortDirection, TenantRole, UserServiceClient } from "@scow/protos/build/server/user"; import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; import { createServer } from "src/app"; import { authUrl } from "src/config"; import { Account } from "src/entities/Account"; @@ -31,6 +33,7 @@ import { reloadEntity } from "src/utils/orm"; import { insertInitialData } from "tests/data/data"; import { dropDatabase } from "tests/data/helpers"; +dayjs.extend(utc); const anotherTenant = "anotherTenant"; @@ -566,7 +569,7 @@ it("change an inexistent user email", async () => { expect(reply.code).toBe(Status.NOT_FOUND); }); -it("get new user count", async () => { +it("get new user count in UTC+8 timezone", async () => { const em = server.ext.orm.em.fork(); const today = dayjs(); @@ -607,12 +610,19 @@ it("get new user count", async () => { const info = await asyncClientCall(client, "getNewUserCount", { startTime: twoDaysBefore.startOf("day").toISOString(), endTime: today.endOf("day").toISOString(), + timeZone: "Asia/Shanghai", }); + const todyInUtcPlus8 = today.utcOffset(8); + + const yesterdayInUtcPlus8 = yesterday.utcOffset(8); + + const twoDaysBeforeInUtcPlus8 = twoDaysBefore.utcOffset(8); + expect(info.results).toMatchObject([ - { date: today.startOf("day").toISOString(), count: 30 }, - { date: yesterday.startOf("day").toISOString(), count: 20 }, - { date: twoDaysBefore.startOf("day").toISOString(), count: 10 }, + { date: dayjsToDateMessage(todyInUtcPlus8), count: 30 }, + { date:dayjsToDateMessage(yesterdayInUtcPlus8), count: 20 }, + { date:dayjsToDateMessage(twoDaysBeforeInUtcPlus8), count: 10 }, ]); }); diff --git a/apps/mis-server/tests/charging/statistics.test.ts b/apps/mis-server/tests/charging/statistics.test.ts index 4e4672145f..c090a030f2 100644 --- a/apps/mis-server/tests/charging/statistics.test.ts +++ b/apps/mis-server/tests/charging/statistics.test.ts @@ -15,8 +15,10 @@ import { Server } from "@ddadaal/tsgrpc-server"; import { ChannelCredentials } from "@grpc/grpc-js"; import { SqlEntityManager } from "@mikro-orm/mysql"; import { Decimal, decimalToMoney } from "@scow/lib-decimal"; +import { dayjsToDateMessage } from "@scow/lib-server/build/date"; import { ChargingServiceClient } from "@scow/protos/build/server/charging"; import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; import { createServer } from "src/app"; import { Account } from "src/entities/Account"; import { ChargeRecord } from "src/entities/ChargeRecord"; @@ -24,6 +26,7 @@ import { PayRecord } from "src/entities/PayRecord"; import { Tenant } from "src/entities/Tenant"; import { dropDatabase } from "tests/data/helpers"; +dayjs.extend(utc); let server: Server; let em: SqlEntityManager; @@ -106,21 +109,28 @@ it("correct get Top 10 Charge Account", async () => { }); -it("correct get daily Charge Amount", async () => { +it("correct get daily Charge Amount in UTC+8 timezone", async () => { const client = new ChargingServiceClient(server.serverAddress, ChannelCredentials.createInsecure()); const today = dayjs().startOf("day"); const tenDaysAgo = today.clone().subtract(9, "day"); - const reply = await asyncClientCall(client, "getDailyCharge", - { startTime: tenDaysAgo.toISOString(), endTime: today.toISOString() }); + const reply = await asyncClientCall(client, "getDailyCharge", { + startTime: tenDaysAgo.toISOString(), + endTime: today.toISOString(), + timeZone: "Asia/Shanghai", + }); - const results = Array.from({ length: 10 }, (_, i) => ({ - date: today.subtract(i, "day").toISOString(), - amount: decimalToMoney(new Decimal(100 * (11 - (i + 1)))), - })); + const todayInUtcPlus8 = today.utcOffset(8); + const results = Array.from({ length: 10 }, (_, i) => { + const curDateInUtcPlus8 = todayInUtcPlus8.subtract(i, "day"); + return ({ + date: dayjsToDateMessage(curDateInUtcPlus8), + amount: decimalToMoney(new Decimal(100 * (11 - (i + 1)))), + }); + }); expect(reply.results).toMatchObject(results); @@ -147,20 +157,28 @@ it("correct get Top 10 Pay Account", async () => { }); -it("correct get daily Pay Amount", async () => { +it("correct get daily Pay Amount in UTC+8 timezone", async () => { const client = new ChargingServiceClient(server.serverAddress, ChannelCredentials.createInsecure()); const today = dayjs().startOf("day"); const tenDaysAgo = today.clone().subtract(9, "day"); - const reply = await asyncClientCall(client, "getDailyPay", - { startTime: tenDaysAgo.toISOString(), endTime: today.toISOString() }); + const reply = await asyncClientCall(client, "getDailyPay", { + startTime: tenDaysAgo.toISOString(), + endTime: today.toISOString(), + timeZone: "Asia/Shanghai", + }); - const results = Array.from({ length: 10 }, (_, i) => ({ - date: today.subtract(i, "day").toISOString(), - amount: decimalToMoney(new Decimal(100 * (11 - (i + 1)))), - })); + const todayInUtcPlus8 = today.utcOffset(8); + + const results = Array.from({ length: 10 }, (_, i) => { + const curDateInUtcPlus8 = todayInUtcPlus8.subtract(i, "day"); + return ({ + date: dayjsToDateMessage(curDateInUtcPlus8), + amount: decimalToMoney(new Decimal(100 * (11 - (i + 1)))), + }); + }); expect(reply.results).toMatchObject(results); diff --git a/apps/mis-server/tests/job/JobService.test.ts b/apps/mis-server/tests/job/JobService.test.ts index 6689470ed1..99e7f591bb 100644 --- a/apps/mis-server/tests/job/JobService.test.ts +++ b/apps/mis-server/tests/job/JobService.test.ts @@ -15,8 +15,10 @@ import { Server } from "@ddadaal/tsgrpc-server"; import { ChannelCredentials } from "@grpc/grpc-js"; import { SqlEntityManager } from "@mikro-orm/mysql"; import { Decimal, moneyToNumber, numberToMoney } from "@scow/lib-decimal"; +import { dayjsToDateMessage } from "@scow/lib-server/build/date"; import { JobFilter, JobServiceClient } from "@scow/protos/build/server/job"; import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; import { createServer } from "src/app"; import { JobInfo } from "src/entities/JobInfo"; import { JobPriceChange } from "src/entities/JobPriceChange"; @@ -26,6 +28,8 @@ import { reloadEntities } from "src/utils/orm"; import { InitialData, insertInitialData } from "tests/data/data"; import { dropDatabase } from "tests/data/helpers"; +dayjs.extend(utc); + let server: Server; let em: SqlEntityManager; let data: InitialData; @@ -246,42 +250,54 @@ it("get Top Submit Job Users correctly", async () => { }); -it("get new job count correctly", async () => { +it("get new job count correctly in UTC+8 timezone", async () => { const today = dayjs(); - console.log(today.toISOString()); + const yesterday = today.clone().subtract(1, "day"); + + const twoDaysBefore = today.clone().subtract(2, "day"); + + const threeDaysBofre = today.clone().subtract(3, "day"); + + const todayJobs = range(0, 20).map((_) => mockOriginalJobData(data.uaAA, new Decimal(20), new Decimal(10), today.toDate())); const yesterdayJobs = range(0, 30).map((_) => - mockOriginalJobData(data.uaAA, new Decimal(20), new Decimal(10), today.clone().subtract(1, "day").toDate())); - const twoDayBeforeJobs = range(0, 15).map((_) => - mockOriginalJobData(data.uaAA, new Decimal(20), new Decimal(10), today.clone().subtract(2, "day").toDate())); - const threeDayBeforeJobs = range(0, 1).map((_) => - mockOriginalJobData(data.uaAA, new Decimal(20), new Decimal(10), today.clone().subtract(3, "day").toDate())); - await em.persistAndFlush([...todayJobs, ...yesterdayJobs, ...twoDayBeforeJobs, ...threeDayBeforeJobs]); + mockOriginalJobData(data.uaAA, new Decimal(20), new Decimal(10), yesterday.toDate())); + const twoDaysBeforeJobs = range(0, 15).map((_) => + mockOriginalJobData(data.uaAA, new Decimal(20), new Decimal(10), twoDaysBefore.toDate())); + const threeDaysBeforeJobs = range(0, 1).map((_) => + mockOriginalJobData(data.uaAA, new Decimal(20), new Decimal(10), threeDaysBofre.toDate())); + await em.persistAndFlush([...todayJobs, ...yesterdayJobs, ...twoDaysBeforeJobs, ...threeDaysBeforeJobs]); const client = createClient(); const reply = await asyncClientCall(client, "getNewJobCount", { startTime: today.clone().subtract(3, "day").startOf("day").toISOString(), endTime: today.endOf("day").toISOString(), + timeZone: "Asia/Shanghai", }); + const todayInUtcPlus8 = today.utcOffset(8); + const yesterdayInUtcPlus8 = yesterday.utcOffset(8); + const twoDaysBeforeInUtcPlus8 = twoDaysBefore.utcOffset(8); + const threeDaysBeforeInUtcPlus8 = threeDaysBofre.utcOffset(8); + expect(reply.results).toMatchObject([ { - date: today.startOf("day").toISOString(), + date: dayjsToDateMessage(todayInUtcPlus8), count: 20, }, { - date: today.clone().subtract(1, "day").startOf("day").toISOString(), + date: dayjsToDateMessage(yesterdayInUtcPlus8), count: 30, }, { - date: today.clone().subtract(2, "day").startOf("day").toISOString(), + date: dayjsToDateMessage(twoDaysBeforeInUtcPlus8), count: 15, }, { - date: today.clone().subtract(3, "day").startOf("day").toISOString(), + date: dayjsToDateMessage(threeDaysBeforeInUtcPlus8), count: 1, }, diff --git a/apps/mis-web/src/apis/api.mock.ts b/apps/mis-web/src/apis/api.mock.ts index 0ac419600d..0bcc0548bb 100644 --- a/apps/mis-web/src/apis/api.mock.ts +++ b/apps/mis-web/src/apis/api.mock.ts @@ -293,7 +293,7 @@ export const mockApi: MockApi = { getTopSubmitJobUser: async () => ({ results: [{ userId: "test", count:10 }]}), - getNewJobCount: async () => ({ results: [{ date: new Date().toISOString(), count: 10 }]}), + getNewJobCount: async () => ({ results: [{ date: { year: 2023, month: 12, day: 21 }, count: 10 }]}), getTenantUsers: async () => ({ results: mockUsers }), @@ -388,12 +388,12 @@ export const mockApi: MockApi = { unblockUserInAccount: async () => ({ executed: true }), blockAccount: async () => ({ executed: true }), unblockAccount: async () => ({ executed: true }), - getNewUserCount: async () => ({ results: [{ date: new Date().toISOString(), count: 10 }]}), - getActiveUserCount: async () => ({ results: [{ date: new Date().toISOString(), count: 10 }]}), + getNewUserCount: async () => ({ results: [{ date: { year: 2023, month: 12, day: 21 }, count: 10 }]}), + getActiveUserCount: async () => ({ results: [{ date: { year: 2023, month: 12, day: 21 }, count: 10 }]}), getTopChargeAccount: async () => ({ results: [{ accountName: "test", chargedAmount: numberToMoney(10) }]}), - getDailyCharge: async () => ({ results: [{ date: new Date().toISOString(), amount: numberToMoney(10) }]}), + getDailyCharge: async () => ({ results: [{ date: { year: 2023, month: 12, day: 21 }, amount: numberToMoney(10) }]}), getTopPayAccount: async () => ({ results: [{ accountName: "test", payAmount: numberToMoney(10) }]}), - getDailyPay: async () => ({ results: [{ date: new Date().toISOString(), amount: numberToMoney(10) }]}), + getDailyPay: async () => ({ results: [{ date: { year: 2023, month: 12, day: 21 }, amount: numberToMoney(10) }]}), getPortalUsageCount: async () => ({ results: [{ operationType: "submitJob", count: 10 }]}), getMisUsageCount: async () => ({ results: [{ operationType: "createAccount", count: 10 }]}), getStatisticInfo: async () => diff --git a/apps/mis-web/src/models/date.ts b/apps/mis-web/src/models/date.ts new file mode 100644 index 0000000000..5d535909a5 --- /dev/null +++ b/apps/mis-web/src/models/date.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { Type } from "@sinclair/typebox"; + +export const DateSchema = Type.Object({ + year: Type.Number(), + month: Type.Number(), + day: Type.Number(), +}); diff --git a/apps/mis-web/src/pages/admin/statistic.tsx b/apps/mis-web/src/pages/admin/statistic.tsx index e8ad6529a2..f585d9b29a 100644 --- a/apps/mis-web/src/pages/admin/statistic.tsx +++ b/apps/mis-web/src/pages/admin/statistic.tsx @@ -28,15 +28,20 @@ import { PlatformRole, SearchType } from "src/models/User"; import { DataBarChart } from "src/pageComponents/admin/DataBarChart"; import { DataLineChart } from "src/pageComponents/admin/DataLineChart"; import { StatisticCard } from "src/pageComponents/admin/StatisticCard"; +import { dateMessageToDayjs } from "src/utils/date"; import { Head } from "src/utils/head"; import { styled } from "styled-components"; +const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; const p = prefix("page.admin.statistic."); -const formateData = (data: Array<{ date: string, count: number }>, dateRange: [dayjs.Dayjs, dayjs.Dayjs]) => { +const formateData = (data: Array<{ + date: { year: number, month: number, day: number }, + count: number +}>, dateRange: [dayjs.Dayjs, dayjs.Dayjs]) => { const input = data.map((d) => ({ - date: new Date(d.date), + date: dateMessageToDayjs(d.date), count: d.count, })); const countData: {date: dayjs.Dayjs, count: number}[] = []; @@ -116,6 +121,7 @@ requireAuth((u) => u.platformRoles.includes(PlatformRole.PLATFORM_ADMIN)) return await api.getNewUserCount({ query: { startTime: query.filterTime[0].startOf("day").toISOString(), endTime: query.filterTime[1].endOf("day").toISOString(), + timeZone, } }); }, [query]); @@ -125,6 +131,7 @@ requireAuth((u) => u.platformRoles.includes(PlatformRole.PLATFORM_ADMIN)) return await api.getActiveUserCount({ query: { startTime: query.filterTime[0].startOf("day").toISOString(), endTime: query.filterTime[1].endOf("day").toISOString(), + timeZone, } }); }, [query]); @@ -152,6 +159,7 @@ requireAuth((u) => u.platformRoles.includes(PlatformRole.PLATFORM_ADMIN)) return await api.getDailyCharge({ query: { startTime: query.filterTime[0].startOf("day").toISOString(), endTime: query.filterTime[1].endOf("day").toISOString(), + timeZone, } }); }, [query]); @@ -161,6 +169,7 @@ requireAuth((u) => u.platformRoles.includes(PlatformRole.PLATFORM_ADMIN)) return await api.getDailyPay({ query: { startTime: query.filterTime[0].startOf("day").toISOString(), endTime: query.filterTime[1].endOf("day").toISOString(), + timeZone, } }); }, [query]); @@ -179,6 +188,7 @@ requireAuth((u) => u.platformRoles.includes(PlatformRole.PLATFORM_ADMIN)) return await api.getNewJobCount({ query: { startTime: query.filterTime[0].startOf("day").toISOString(), endTime: query.filterTime[1].endOf("day").toISOString(), + timeZone, } }); }, [query]); diff --git a/apps/mis-web/src/pages/api/admin/getActiveUserCount.ts b/apps/mis-web/src/pages/api/admin/getActiveUserCount.ts index c9146f0170..0c1c120b19 100644 --- a/apps/mis-web/src/pages/api/admin/getActiveUserCount.ts +++ b/apps/mis-web/src/pages/api/admin/getActiveUserCount.ts @@ -15,12 +15,13 @@ import { asyncClientCall } from "@ddadaal/tsgrpc-client"; import { StatisticServiceClient } from "@scow/protos/build/audit/statistic"; import { Static, Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { DateSchema } from "src/models/date"; import { PlatformRole } from "src/models/User"; import { getAuditClient } from "src/utils/client"; export const GetActiveUserCountResponse = Type.Object({ results: Type.Array(Type.Object({ - date: Type.String(), + date: DateSchema, count: Type.Number(), })), }); @@ -37,6 +38,8 @@ export const GetActiveUserCountSchema = typeboxRouteSchema({ endTime: Type.String({ format: "date-time" }), + timeZone: Type.String(), + }), responses: { @@ -54,7 +57,7 @@ export default typeboxRoute(GetActiveUserCountSchema, return; } - const { startTime, endTime } = req.query; + const { startTime, endTime, timeZone } = req.query; const client = getAuditClient?.(StatisticServiceClient); @@ -62,13 +65,17 @@ export default typeboxRoute(GetActiveUserCountSchema, const { results } = await asyncClientCall(client, "getActiveUserCount", { startTime, endTime, + timeZone, }); + return { 200: { - results: results.map((result) => ({ - date: result.date || "", - count: result.count, - })), + results: results + .filter((x) => x.date !== undefined) + .map((x) => ({ + date: x.date!, + count: x.count, + })), }, }; } diff --git a/apps/mis-web/src/pages/api/admin/getDailyCharge.ts b/apps/mis-web/src/pages/api/admin/getDailyCharge.ts index a1044ff0a7..06c72a77f5 100644 --- a/apps/mis-web/src/pages/api/admin/getDailyCharge.ts +++ b/apps/mis-web/src/pages/api/admin/getDailyCharge.ts @@ -15,6 +15,7 @@ import { asyncClientCall } from "@ddadaal/tsgrpc-client"; import { ChargingServiceClient } from "@scow/protos/build/server/charging"; import { Static, Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { DateSchema } from "src/models/date"; import { PlatformRole } from "src/models/User"; import { Money } from "src/models/UserSchemaModel"; import { ensureNotUndefined } from "src/utils/checkNull"; @@ -22,7 +23,7 @@ import { getClient } from "src/utils/client"; export const GetDailyChargeResponse = Type.Object({ results: Type.Array(Type.Object({ - date: Type.String(), + date: DateSchema, amount: Money, })), }); @@ -39,6 +40,8 @@ export const GetDailyChargeSchema = typeboxRouteSchema({ endTime: Type.String({ format: "date-time" }), + timeZone: Type.String(), + }), responses: { @@ -56,18 +59,21 @@ export default typeboxRoute(GetDailyChargeSchema, return; } - const { startTime, endTime } = req.query; + const { startTime, endTime, timeZone } = req.query; const client = getClient(ChargingServiceClient); const { results } = await asyncClientCall(client, "getDailyCharge", { startTime, endTime, + timeZone, }); return { 200: { - results: results.map((x) => ensureNotUndefined(x, ["date", "amount"])), + results: results + .filter((x) => x.date !== undefined) + .map((x) => ensureNotUndefined(x, ["date", "amount"])), }, }; }); diff --git a/apps/mis-web/src/pages/api/admin/getDailyPay.ts b/apps/mis-web/src/pages/api/admin/getDailyPay.ts index 82b5295e40..16558f8d93 100644 --- a/apps/mis-web/src/pages/api/admin/getDailyPay.ts +++ b/apps/mis-web/src/pages/api/admin/getDailyPay.ts @@ -15,6 +15,7 @@ import { asyncClientCall } from "@ddadaal/tsgrpc-client"; import { ChargingServiceClient } from "@scow/protos/build/server/charging"; import { Static, Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { DateSchema } from "src/models/date"; import { PlatformRole } from "src/models/User"; import { Money } from "src/models/UserSchemaModel"; import { ensureNotUndefined } from "src/utils/checkNull"; @@ -22,7 +23,7 @@ import { getClient } from "src/utils/client"; export const GetDailyPayResponse = Type.Object({ results: Type.Array(Type.Object({ - date: Type.String(), + date: DateSchema, amount: Money, })), }); @@ -39,6 +40,8 @@ export const GetDailyPaySchema = typeboxRouteSchema({ endTime: Type.String({ format: "date-time" }), + timeZone: Type.String(), + }), responses: { @@ -56,18 +59,21 @@ export default typeboxRoute(GetDailyPaySchema, return; } - const { startTime, endTime } = req.query; + const { startTime, endTime, timeZone } = req.query; const client = getClient(ChargingServiceClient); const { results } = await asyncClientCall(client, "getDailyPay", { startTime, endTime, + timeZone, }); return { 200: { - results: results.map((x) => ensureNotUndefined(x, ["date", "amount"])), + results: results + .filter((x) => x.date !== undefined) + .map((x) => ensureNotUndefined(x, ["date", "amount"])), }, }; }); diff --git a/apps/mis-web/src/pages/api/admin/getNewJobCount.ts b/apps/mis-web/src/pages/api/admin/getNewJobCount.ts index 52f807a28b..78290ea714 100644 --- a/apps/mis-web/src/pages/api/admin/getNewJobCount.ts +++ b/apps/mis-web/src/pages/api/admin/getNewJobCount.ts @@ -15,13 +15,14 @@ import { asyncClientCall } from "@ddadaal/tsgrpc-client"; import { JobServiceClient } from "@scow/protos/build/server/job"; import { Static, Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { DateSchema } from "src/models/date"; import { PlatformRole } from "src/models/User"; import { ensureNotUndefined } from "src/utils/checkNull"; import { getClient } from "src/utils/client"; export const GetNewJobCountResponse = Type.Object({ results: Type.Array(Type.Object({ - date: Type.String(), + date: DateSchema, count: Type.Number(), })), }); @@ -38,6 +39,8 @@ export const GetNewJobCountSchema = typeboxRouteSchema({ endTime: Type.String({ format: "date-time" }), + timeZone: Type.String(), + }), responses: { @@ -55,18 +58,20 @@ export default typeboxRoute(GetNewJobCountSchema, return; } - const { startTime, endTime } = req.query; + const { startTime, endTime, timeZone } = req.query; const client = getClient(JobServiceClient); const { results } = await asyncClientCall(client, "getNewJobCount", { startTime, endTime, + timeZone, }); return { 200: { - results: results.map((x) => ensureNotUndefined(x, ["date"])), + results: results.filter((x) => x.date !== undefined) + .map((x) => ensureNotUndefined(x, ["date"])), }, }; }); diff --git a/apps/mis-web/src/pages/api/admin/getNewUserCount.ts b/apps/mis-web/src/pages/api/admin/getNewUserCount.ts index d9905413ff..11e70d7916 100644 --- a/apps/mis-web/src/pages/api/admin/getNewUserCount.ts +++ b/apps/mis-web/src/pages/api/admin/getNewUserCount.ts @@ -15,12 +15,13 @@ import { asyncClientCall } from "@ddadaal/tsgrpc-client"; import { UserServiceClient } from "@scow/protos/build/server/user"; import { Static, Type } from "@sinclair/typebox"; import { authenticate } from "src/auth/server"; +import { DateSchema } from "src/models/date"; import { PlatformRole } from "src/models/User"; import { getClient } from "src/utils/client"; export const GetNewUserCountResponse = Type.Object({ results: Type.Array(Type.Object({ - date: Type.String(), + date: DateSchema, count: Type.Number(), })), }); @@ -37,6 +38,8 @@ export const GetNewUserCountSchema = typeboxRouteSchema({ endTime: Type.String({ format: "date-time" }), + timeZone: Type.String(), + }), responses: { @@ -54,21 +57,24 @@ export default typeboxRoute(GetNewUserCountSchema, return; } - const { startTime, endTime } = req.query; + const { startTime, endTime, timeZone } = req.query; const client = getClient(UserServiceClient); const { results } = await asyncClientCall(client, "getNewUserCount", { startTime, endTime, + timeZone: timeZone, }); return { 200: { - results: results.map((result) => ({ - date: result.date || "", - count: result.count, - })), + results: results + .filter((x) => x.date !== undefined) + .map((x) => ({ + date: x.date!, + count: x.count, + })), }, }; }); diff --git a/apps/mis-web/src/utils/date.ts b/apps/mis-web/src/utils/date.ts new file mode 100644 index 0000000000..dec0b249da --- /dev/null +++ b/apps/mis-web/src/utils/date.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import dayjs, { Dayjs } from "dayjs"; + +interface DateMessage { + year: number; + month: number; + day: number; +} + +export function dateMessageToDayjs(dateMessage: DateMessage): Dayjs { + const { year, month, day } = dateMessage; + + let date: Dayjs; + if (year === 0) { + // 没有年份信息,返回当前日期 + date = dayjs(); + } else if (month === 0) { + // 只有年份信息,返回该年的1月1日 + date = dayjs(`${year}`); + } else if (day === 0) { + // 有年份和月份,但没有日信息,返回该月的第一天 + date = dayjs(`${year}-${month}`); + } else { + // 有年、月、日信息 + date = dayjs(`${year}-${month}-${day}`); + } + + return date; +} + diff --git a/docs/docs/integration/index.md b/docs/docs/integration/index.md index fdad738c8b..95cba382fa 100644 --- a/docs/docs/integration/index.md +++ b/docs/docs/integration/index.md @@ -12,6 +12,6 @@ title: 与SCOW集成 - 自定义认证系统 - [使用自定义认证系统](./auth/use.md) - [实现自定义认证系统](./auth/impl.md) -- [SCOW API](./scow-api-hook/api.md):通过编程调用SCOW的功能 +- [SCOW API](./scow-api-hook/api/api.md):通过编程调用SCOW的功能 - [SCOW Hook](./scow-api-hook/hook.md):监听SCOW的事件 - [UI扩展](./ui-extension/ui-extension.md):将您的UI集成进SCOW \ No newline at end of file diff --git a/docs/docs/integration/scow-api-hook/api.md b/docs/docs/integration/scow-api-hook/api/api.md similarity index 91% rename from docs/docs/integration/scow-api-hook/api.md rename to docs/docs/integration/scow-api-hook/api/api.md index 09c28b3936..03bb0a2886 100644 --- a/docs/docs/integration/scow-api-hook/api.md +++ b/docs/docs/integration/scow-api-hook/api/api.md @@ -1,15 +1,15 @@ --- -sidebar_position: 2 +sidebar_position: 1 title: SCOW API --- # SCOW API -SCOW系统总体来说分为前端和后端部分([架构](../../deploy/architecture/index.md)),SCOW的前端和后端部分使用gRPC进行通信。 +SCOW系统总体来说分为前端和后端部分([架构](../../../deploy/architecture/index.md)),SCOW的前端和后端部分使用gRPC进行通信。 要使用SCOW API,您需要 -1. [获取SCOW Protobuf文件](./proto.md)并生成相关代码 +1. [获取SCOW Protobuf文件](../proto.md)并生成相关代码 2. 编写程序,调用gRPC API与SCOW的后端部分组件`mis-server`, `portal-server`, `audit-server`交互 ## 打开后端服务网络接口 @@ -64,4 +64,4 @@ scowApi: ## 实际项目示例 -- [Go](./examples/go.md#使用scow-api) +- [Go](../examples/go.md#使用scow-api) diff --git a/docs/docs/integration/scow-api-hook/api/statistic.md b/docs/docs/integration/scow-api-hook/api/statistic.md new file mode 100644 index 0000000000..da89177d23 --- /dev/null +++ b/docs/docs/integration/scow-api-hook/api/statistic.md @@ -0,0 +1,58 @@ +--- +sidebar_position: 2 +title: 数据统计相关API +--- + +# 数据统计相关API + +SCOW系统提供了一些数据统计相关的API,您可以通过这些API获取SCOW系统的一些统计数据。其中有部分API是以日期为维度进行的统计,您可以通过这些API获取x天内每一天的统计数据。但由于数据库里采用的是UTC时间,如果希望统计的维度和客户端一致,在使用这些API时,您需要注意时区的问题。 + +## 相关API + +- `GetActiveUserCount`:获取x天内每一天的用户登录次数 +- `GetNewUserCount`:获取x天内每一天的新用户注册数 +- `GetNewJobCount`:获取x天内每一天的新作业提交数 +- `GetDailyCharge`:获取x天内每一天的用户消费金额总计 +- `GetDailyPay`: 获取x天内每一天的用户充值金额总计 + +## 参数 TimeZone + +以上API在调用时都需要传timeZone参数,这个参数是用来指定统计的时区。如果不传timeZone参数,统计的时区默认为UTC。如果希望统计的维度和客户端一致,您需要传入timeZone参数。 + + +timeZone参数请遵循以下格式指南: + +1. **UTC偏移量**: 使用格式+HH:MM或-HH:MM表示相对于UTC的偏移。例如,+08:00表示东八区。 + +2. **时区名称**: 使用具体的地理时区名称,如Asia/Shanghai或Europe/London。这些名称代表特定地区的标准时间。 + +请根据您的需求选择以上一种格式来指定时区。 + +## 可用时区名称及UTC偏移量 + +- **UTC-12:00** `Etc/GMT+12` - `-12:00` +- **UTC-11:00** `Pacific/Pago_Pago` - `-11:00` +- **UTC-10:00** `Pacific/Honolulu` - `-10:00` +- **UTC-09:00** `America/Anchorage` - `-09:00` +- **UTC-08:00** `America/Los_Angeles` - `-08:00` +- **UTC-07:00** `America/Denver` - `-07:00` +- **UTC-06:00** `America/Chicago` - `-06:00` +- **UTC-05:00** `America/New_York` - `-05:00` +- **UTC-04:00** `America/Caracas` - `-04:00` +- **UTC-03:00** `America/Argentina/Buenos_Aires` - `-03:00` +- **UTC-02:00** `Atlantic/South_Georgia` - `-02:00` +- **UTC-01:00** `Atlantic/Azores` - `-01:00` +- **UTC+00:00** `UTC` - `+00:00` +- **UTC+01:00** `Europe/Paris` - `+01:00` +- **UTC+02:00** `Europe/Athens` - `+02:00` +- **UTC+03:00** `Europe/Moscow` - `+03:00` +- **UTC+04:00** `Asia/Dubai` - `+04:00` +- **UTC+05:00** `Asia/Karachi` - `+05:00` +- **UTC+06:00** `Asia/Dhaka` - `+06:00` +- **UTC+07:00** `Asia/Bangkok` - `+07:00` +- **UTC+08:00** `Asia/Shanghai` - `+08:00` +- **UTC+09:00** `Asia/Tokyo` - `+09:00` +- **UTC+10:00** `Australia/Sydney` - `+10:00` +- **UTC+11:00** `Pacific/Noumea` - `+11:00` +- **UTC+12:00** `Pacific/Fiji` - `+12:00` + diff --git a/libs/server/package.json b/libs/server/package.json index 68bf20df00..849aaccda6 100644 --- a/libs/server/package.json +++ b/libs/server/package.json @@ -22,6 +22,7 @@ "@grpc/grpc-js": "1.9.14", "nookies": "2.5.2", "@scow/config": "workspace:*", + "dayjs": "1.11.10", "@scow/lib-scheduler-adapter": "workspace:*", "@scow/utils": "workspace:*", "@scow/rich-error-model": "workspace:*", diff --git a/libs/server/src/date.ts b/libs/server/src/date.ts new file mode 100644 index 0000000000..eec031e2a1 --- /dev/null +++ b/libs/server/src/date.ts @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { ServiceError } from "@ddadaal/tsgrpc-common"; +import { Logger } from "@ddadaal/tsgrpc-server"; +import { status } from "@grpc/grpc-js"; +import { DateMessage } from "@scow/protos/build/google/type/date"; +import dayjs, { Dayjs } from "dayjs"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; + +// 使用插件 +dayjs.extend(utc); +dayjs.extend(timezone); + + +function isValidDate(year: number, month: number, day: number): boolean { + const date = new Date(year, month - 1, day); + return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day; +} + + +export function convertToDateMessage(dateStr: string, logger: Logger): DateMessage | undefined { + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + logger.error(`Invalid date format: ${dateStr}`); + return; + } + + const [year, month, day] = dateStr.split("-").map(Number); + if (!isValidDate(year, month, day)) { + logger.error(`Invalid date in: ${dateStr}`); + return; + } + + return DateMessage.create({ year, month, day }); +} + + +export function isValidTimezone(timezoneStr: string) { + try { + // 检查是否是有效的 UTC 偏移量格式 + const utcOffsetPattern = /^[+-](?:2[0-3]|[01][0-9]):[0-5][0-9]$/; + if (utcOffsetPattern.test(timezoneStr)) { + // 使用 dayjs 解析 UTC 偏移量 + const testDate = dayjs("2024-01-01").utcOffset(timezoneStr); + return testDate.format("Z") === timezoneStr; + } else { + // 尝试使用 timezone 插件解析时区名称 + dayjs.tz("2024-01-01", timezoneStr); + // 如果 dayjs 能够正确解析时区名称,函数将正常运行并返回 true + return true; + } + } catch (e) { + // 如果发生错误,说明时区字符串无效 + return false; + } +} + + +// 将 Dayjs 对象转换为 DateMessage +export function dayjsToDateMessage(dayjsObj: Dayjs): DateMessage { + return DateMessage.create({ + year: dayjsObj.year(), + month: dayjsObj.month() + 1, // Dayjs 的月份是从 0 开始计数的 + day: dayjsObj.date(), + }); +} + +export function checktTimeZone(timeZone: string) { + if (!isValidTimezone(timeZone)) { + throw { + code: status.INVALID_ARGUMENT, + message: "Invalid timezone", + }; + } +} diff --git a/libs/server/src/index.ts b/libs/server/src/index.ts index 25f44004ae..89cb32129c 100644 --- a/libs/server/src/index.ts +++ b/libs/server/src/index.ts @@ -12,5 +12,6 @@ export * from "./apiAuthPlugin"; export * from "./app"; +export * from "./date"; export * from "./scheduleAdapter"; export * from "./systemLanguage"; diff --git a/libs/server/tests/date.test.ts b/libs/server/tests/date.test.ts new file mode 100644 index 0000000000..ac34f8e2da --- /dev/null +++ b/libs/server/tests/date.test.ts @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { Logger } from "@ddadaal/tsgrpc-server"; +import { DateMessage } from "@scow/protos/build/google/type/date"; +import dayjs from "dayjs"; +import { convertToDateMessage, dayjsToDateMessage, isValidTimezone } from "src/date"; + +const mockLogger = { + error: jest.fn(), +}; + +it("returns a DateMessage for valid date string", () => { + const dateStr = "2021-12-31"; + const result = convertToDateMessage(dateStr, mockLogger as unknown as Logger); + expect(result).toEqual(DateMessage.create({ year: 2021, month: 12, day: 31 })); +}); + +it("returns undefined for invalid date format", () => { + const dateStr = "2021-12-31-01"; + const result = convertToDateMessage(dateStr, mockLogger as unknown as Logger); + expect(result).toBeUndefined(); + expect(mockLogger.error).toHaveBeenCalledWith(`Invalid date format: ${dateStr}`); +}); + +it("returns undefined for invalid date", () => { + const dateStr = "2021-02-30"; + const result = convertToDateMessage(dateStr, mockLogger as unknown as Logger); + expect(result).toBeUndefined(); + expect(mockLogger.error).toHaveBeenCalledWith(`Invalid date in: ${dateStr}`); +}); + + +describe("isValidTimezone", () => { + it("should return true for valid UTC offset \"+08:00\"", () => { + expect(isValidTimezone("+08:00")).toBe(true); + }); + + it("should return true for valid UTC offset \"-05:00\"", () => { + expect(isValidTimezone("-05:00")).toBe(true); + }); + + it("should return false for invalid UTC offset \"+25:00\"", () => { + expect(isValidTimezone("+25:00")).toBe(false); + }); + + it("should return true for valid timezone name \"Asia/Shanghai\"", () => { + expect(isValidTimezone("Asia/Shanghai")).toBe(true); + }); + + it("should return true for valid timezone name \"Europe/Paris\"", () => { + expect(isValidTimezone("Europe/Paris")).toBe(true); + }); + + it("should return false for invalid timezone name \"Invalid/Timezone\"", () => { + expect(isValidTimezone("Invalid/Timezone")).toBe(false); + }); + + it("should return true for UTC", () => { + expect(isValidTimezone("UTC")).toBe(true); + }); + +}); + + +describe("dayjsToDateMessage", () => { + it("should convert Dayjs object to DateMessage correctly", () => { + const date = dayjs(new Date(2024, 0, 15)); + const result = dayjsToDateMessage(date); + expect(result).toEqual({ year: 2024, month: 1, day: 15 }); + }); + +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cad0ac12e6..56b256abba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -419,6 +419,9 @@ importers: '@scow/lib-operation-log': specifier: workspace:* version: link:../../libs/operation-log + '@scow/lib-server': + specifier: workspace:* + version: link:../../libs/server '@scow/protos': specifier: workspace:* version: link:../../libs/protos/scow @@ -1429,6 +1432,9 @@ importers: '@scow/config': specifier: workspace:* version: link:../config + dayjs: + specifier: 1.11.10 + version: 1.11.10 '@scow/lib-scheduler-adapter': specifier: workspace:* version: link:../scheduler-adapter diff --git a/protos/audit/statistic.proto b/protos/audit/statistic.proto index 2b68520bcd..bdae3adede 100644 --- a/protos/audit/statistic.proto +++ b/protos/audit/statistic.proto @@ -4,17 +4,19 @@ package scow.audit; import "google/protobuf/timestamp.proto"; +import "google/type/date.proto"; message Data { - // 如果此API返回值只包含date,请使用protobuf专门用来代表日期的类型:https://github.com/googleapis/googleapis/blob/master/google/type/date.proto - // 注意返回的Date为UTC时间的date - google.protobuf.Timestamp date = 1; + google.type.Date date = 1; uint32 count = 2; } message GetActiveUserCountRequest { google.protobuf.Timestamp start_time = 1; google.protobuf.Timestamp end_time = 2; + // 以什么时区作为统计的基准,如果不传,默认为UTC + // 可选时区根据mysql的时区确定,具体可参考:https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html + optional string time_zone = 3; } message GetActiveUserCountResponse { diff --git a/protos/google/type/date.proto b/protos/google/type/date.proto new file mode 100644 index 0000000000..e4e730e6f5 --- /dev/null +++ b/protos/google/type/date.proto @@ -0,0 +1,52 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package google.type; + +option cc_enable_arenas = true; +option go_package = "google.golang.org/genproto/googleapis/type/date;date"; +option java_multiple_files = true; +option java_outer_classname = "DateProto"; +option java_package = "com.google.type"; +option objc_class_prefix = "GTP"; + +// Represents a whole or partial calendar date, such as a birthday. The time of +// day and time zone are either specified elsewhere or are insignificant. The +// date is relative to the Gregorian Calendar. This can represent one of the +// following: +// +// * A full date, with non-zero year, month, and day values +// * A month and day value, with a zero year, such as an anniversary +// * A year on its own, with zero month and day values +// * A year and month value, with a zero day, such as a credit card expiration +// date +// +// Related types are [google.type.TimeOfDay][google.type.TimeOfDay] and +// `google.protobuf.Timestamp`. +message Date { + // Year of the date. Must be from 1 to 9999, or 0 to specify a date without + // a year. + int32 year = 1; + + // Month of a year. Must be from 1 to 12, or 0 to specify a year without a + // month and day. + int32 month = 2; + + // Day of a month. Must be from 1 to 31 and valid for the year and month, or 0 + // to specify a year by itself or a year and month where the day isn't + // significant. + int32 day = 3; +} diff --git a/protos/server/charging.proto b/protos/server/charging.proto index d696d751fd..43e1905812 100644 --- a/protos/server/charging.proto +++ b/protos/server/charging.proto @@ -14,6 +14,7 @@ syntax = "proto3"; package scow.server; import "google/protobuf/timestamp.proto"; +import "google/type/date.proto"; import "google/protobuf/struct.proto"; import "common/money.proto"; @@ -223,13 +224,16 @@ message GetTopChargeAccountResponse { } message DailyAmount { - google.protobuf.Timestamp date = 1; + google.type.Date date = 1; common.Money amount = 2; } message GetDailyChargeRequest { google.protobuf.Timestamp start_time = 1; google.protobuf.Timestamp end_time = 2; + // 以什么时区作为统计的基准,如果不传,默认为UTC + // 可选时区根据mysql的时区确定,具体可参考:https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html + optional string time_zone = 3; } message GetDailyChargeResponse { @@ -256,6 +260,9 @@ message GetTopPayAccountResponse { message GetDailyPayRequest { google.protobuf.Timestamp start_time = 1; google.protobuf.Timestamp end_time = 2; + // 以什么时区作为统计的基准,如果不传,默认为UTC + // 可选时区根据mysql的时区确定,具体可参考:https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html + optional string time_zone = 3; } message GetDailyPayResponse { diff --git a/protos/server/job.proto b/protos/server/job.proto index 849db855b9..41a0b366c0 100644 --- a/protos/server/job.proto +++ b/protos/server/job.proto @@ -18,6 +18,7 @@ import "common/job.proto"; import "common/money.proto"; import "common/ended_job.proto"; import "google/protobuf/timestamp.proto"; +import "google/type/date.proto"; message JobFilter { // if neither account_name and user_id is set, query the account from tenant @@ -170,11 +171,14 @@ message GetTopSubmitJobUsersResponse { message GetNewJobCountRequest { google.protobuf.Timestamp start_time = 1; google.protobuf.Timestamp end_time = 2; + // 以什么时区作为统计的基准,如果不传,默认为UTC + // 可选时区根据mysql的时区确定,具体可参考:https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html + optional string time_zone = 3; } message GetNewJobCountResponse { message DailyJobCount { - google.protobuf.Timestamp date = 1; + google.type.Date date = 1; uint64 count = 2; } repeated DailyJobCount results = 1; diff --git a/protos/server/user.proto b/protos/server/user.proto index ddf3938c8f..50268c4ca9 100644 --- a/protos/server/user.proto +++ b/protos/server/user.proto @@ -17,6 +17,8 @@ package scow.server; import "common/money.proto"; import "google/protobuf/timestamp.proto"; +import "google/type/date.proto"; + enum UserStatus { UNBLOCKED = 0; BLOCKED = 1; @@ -373,11 +375,14 @@ message ChangeEmailResponse { message GetNewUserCountRequest { google.protobuf.Timestamp start_time = 1; google.protobuf.Timestamp end_time = 2; + // 以什么时区作为统计的基准,如果不传,默认为UTC + // 可选时区根据mysql的时区确定,具体可参考:https://dev.mysql.com/doc/refman/8.0/en/time-zone-support.html + optional string time_zone = 3; } message GetNewUserCountResponse { message DailyUserCount { - google.protobuf.Timestamp date = 1; + google.type.Date date = 1; uint64 count = 2; } repeated DailyUserCount results = 1;