Compare commits

..
2 Commits
8 changed files with 87 additions and 31 deletions
+1
View File
@@ -240,6 +240,7 @@ export function adminUsageRoutes() {
}; };
b.key_count += 1; b.key_count += 1;
if ( if (
v.status === "active" &&
v.log_synced_at && v.log_synced_at &&
(b.cutoff === null || v.log_synced_at < b.cutoff) (b.cutoff === null || v.log_synced_at < b.cutoff)
) { ) {
@@ -8,6 +8,7 @@ import dayjs from "dayjs";
import type { PaymentItem } from "@new-api-key-usage/core"; import type { PaymentItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts"; import type { ApiError } from "@/api/http.ts";
import { useCreatePayment, useQpuLatest, useUpdatePayment } from "@/queries/admin.ts"; import { useCreatePayment, useQpuLatest, useUpdatePayment } from "@/queries/admin.ts";
import { unixToBeijingWall, wallAsBeijingIso } from "@/utils/time.ts";
const props = defineProps<{ const props = defineProps<{
open: boolean; open: boolean;
@@ -67,9 +68,12 @@ watch(
if (ed) { if (ed) {
form.kind = ed.kind; form.kind = ed.kind;
form.paid_amount = ed.paid_amount; form.paid_amount = ed.paid_amount;
form.paid_at = ed.paid_at ? dayjs.unix(ed.paid_at) : null; // 北京墙钟进控件,保存时 wallAsBeijingIso 可无损回写
form.period_start = ed.period_start ? dayjs.unix(ed.period_start) : null; form.paid_at = ed.paid_at != null ? unixToBeijingWall(ed.paid_at) : null;
form.period_end = ed.period_end ? dayjs.unix(ed.period_end) : null; form.period_start =
ed.period_start != null ? unixToBeijingWall(ed.period_start) : null;
form.period_end =
ed.period_end != null ? unixToBeijingWall(ed.period_end) : null;
form.subscription_id = ed.subscription_id; form.subscription_id = ed.subscription_id;
form.remark = ed.remark; form.remark = ed.remark;
form.quotaMode = "quota"; form.quotaMode = "quota";
@@ -91,7 +95,7 @@ watch(
); );
function toIso(d: Dayjs | null): string | null { function toIso(d: Dayjs | null): string | null {
return d ? d.toISOString() : null; return d ? wallAsBeijingIso(d) : null;
} }
async function onSubmit() { async function onSubmit() {
@@ -157,6 +161,7 @@ async function onSubmit() {
:confirm-loading="saving" :confirm-loading="saving"
ok-text="保存" ok-text="保存"
cancel-text="取消" cancel-text="取消"
:styles="{ body: { maxHeight: '62vh', overflowY: 'auto' } }"
@update:open="emit('update:open', $event)" @update:open="emit('update:open', $event)"
@ok="onSubmit" @ok="onSubmit"
> >
@@ -185,10 +190,18 @@ async function onSubmit() {
</a-form-item> </a-form-item>
<template v-if="form.kind === 'subscription'"> <template v-if="form.kind === 'subscription'">
<a-form-item label="期间起" required> <a-form-item label="期间起" required>
<a-date-picker v-model:value="form.period_start" style="width: 100%" /> <a-date-picker
v-model:value="form.period_start"
show-time
style="width: 100%"
/>
</a-form-item> </a-form-item>
<a-form-item label="期间止(空 = 进行中)"> <a-form-item label="期间止(空 = 进行中)">
<a-date-picker v-model:value="form.period_end" style="width: 100%" /> <a-date-picker
v-model:value="form.period_end"
show-time
style="width: 100%"
/>
</a-form-item> </a-form-item>
<a-form-item label="订阅 ID" required> <a-form-item label="订阅 ID" required>
<a-input-number <a-input-number
@@ -2,7 +2,11 @@
import type { Dayjs } from "dayjs"; import type { Dayjs } from "dayjs";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { computed } from "vue"; import { computed } from "vue";
import { recentDaysRange, type RangeValue } from "@/utils/time.ts"; import {
recentDaysRange,
wallAsBeijingIso,
type RangeValue,
} from "@/utils/time.ts";
import type { RangeKind } from "@/composables/use-live-time-range.ts"; import type { RangeKind } from "@/composables/use-live-time-range.ts";
const props = defineProps<{ const props = defineProps<{
@@ -62,8 +66,8 @@ function detectKind(value: RangeValue): RangeKind {
function onChange(dates: Dayjs[] | null) { function onChange(dates: Dayjs[] | null) {
if (!dates || dates.length !== 2 || !dates[0] || !dates[1]) return; if (!dates || dates.length !== 2 || !dates[0] || !dates[1]) return;
const value = { const value = {
from: dates[0].toISOString(), from: wallAsBeijingIso(dates[0]),
to: dates[1].toISOString(), to: wallAsBeijingIso(dates[1]),
}; };
emit("update:modelValue", value); emit("update:modelValue", value);
emit("change", value, detectKind(value)); emit("change", value, detectKind(value));
@@ -74,8 +78,9 @@ function onChange(dates: Dayjs[] | null) {
<a-range-picker <a-range-picker
:value="pickerValue" :value="pickerValue"
:presets="presets" :presets="presets"
format="YYYY-MM-DD" format="YYYY-MM-DD HH:mm:ss"
:placeholder="['开始日期', '结束日期']" :placeholder="['开始时间', '结束时间']"
show-time
@change="onChange" @change="onChange"
/> />
</template> </template>
+38 -6
View File
@@ -10,6 +10,38 @@ dayjs.extend(timezone);
/** 北京时区展示(固定 +08:00,不随主机时区变) */ /** 北京时区展示(固定 +08:00,不随主机时区变) */
const DISPLAY_TZ = "Asia/Shanghai"; const DISPLAY_TZ = "Asia/Shanghai";
/** 请求时间序列化:ISO 8601 带偏移(契约 §4;墙钟按北京时间) */
const REQUEST_OFFSET = "+08:00";
/**
* 绝对时刻(Date / Unix / 带时区的 ISO)→ 北京墙钟 + `+08:00`。
* 先转 Asia/Shanghai,与主机时区无关。
*/
export function toRequestIso(value: Date | string | number | dayjs.Dayjs): string {
return shanghaiIso(dayjs(value as never));
}
/**
* 表单/日历墙钟(用户当作北京时间)→ `+08:00`。
* 不做时区换算,只给墙钟贴偏移。
*/
export function wallAsBeijingIso(value: Date | dayjs.Dayjs): string {
const wall = dayjs(value as never).format("YYYY-MM-DDTHH:mm:ss.SSS");
return `${wall}${REQUEST_OFFSET}`;
}
/** Unix 秒 → 可放进日历控件的「北京墙钟」dayjs */
export function unixToBeijingWall(unixSec: number): dayjs.Dayjs {
return dayjs(
dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD HH:mm:ss.SSS"),
);
}
/** 已在 Asia/Shanghai 上的时刻 → 带偏移 ISO */
function shanghaiIso(d: dayjs.Dayjs): string {
const wall = d.tz(DISPLAY_TZ).format("YYYY-MM-DDTHH:mm:ss.SSS");
return `${wall}${REQUEST_OFFSET}`;
}
/** Unix 秒 → 北京时间「YYYY-MM-DD HH:mm:ss」 */ /** Unix 秒 → 北京时间「YYYY-MM-DD HH:mm:ss」 */
export function formatDateTime(unixSec: number | null | undefined): string { export function formatDateTime(unixSec: number | null | undefined): string {
@@ -29,26 +61,26 @@ export function formatTimeOfDay(unixSec: number | null | undefined): string {
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("HH:mm:ss"); return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("HH:mm:ss");
} }
/** 北京时间日初 → 请求用 ISO 8601 UTC(契约 §4:from/to 收 ISO) */ /** 北京时间日初 → 请求用 ISO(带 +08:00 偏移) */
export function beijingDayStartToIso(date: string): string { export function beijingDayStartToIso(date: string): string {
return dayjs.tz(date, DISPLAY_TZ).startOf("day").toISOString(); return shanghaiIso(dayjs.tz(date, DISPLAY_TZ).startOf("day"));
} }
/** 北京时间日界(次日零点,左闭右开的 to)→ ISO UTC */ /** 北京时间日界(次日零点,左闭右开的 to)→ ISO 带偏移 */
export function beijingDayEndToIso(date: string): string { export function beijingDayEndToIso(date: string): string {
return dayjs.tz(date, DISPLAY_TZ).add(1, "day").startOf("day").toISOString(); return shanghaiIso(dayjs.tz(date, DISPLAY_TZ).add(1, "day").startOf("day"));
} }
export type RangeValue = { from: string; to: string }; export type RangeValue = { from: string; to: string };
/** 快捷项:最近 N 天(含今日),返回 [from, to) 的 ISO UTC */ /** 快捷项:最近 N 天(含今日),返回 [from, to) 的 ISO 带偏移 */
export function recentDaysRange( export function recentDaysRange(
days: number, days: number,
nowMs: number = Date.now(), nowMs: number = Date.now(),
): { from: string; to: string } { ): { from: string; to: string } {
const to = dayjs(nowMs).tz(DISPLAY_TZ).add(1, "day").startOf("day"); const to = dayjs(nowMs).tz(DISPLAY_TZ).add(1, "day").startOf("day");
const from = to.subtract(days, "day"); const from = to.subtract(days, "day");
return { from: from.toISOString(), to: to.toISOString() }; return { from: shanghaiIso(from), to: shanghaiIso(to) };
} }
/** 统计口径标注:「2026-09-02 ~ 10-01」(北京时间日) */ /** 统计口径标注:「2026-09-02 ~ 10-01」(北京时间日) */
+5 -5
View File
@@ -17,7 +17,7 @@ import {
type RangeValue, type RangeValue,
} from "@/composables/use-live-time-range.ts"; } from "@/composables/use-live-time-range.ts";
import { useAdminOverview, useEngine, usePayments } from "@/queries/admin.ts"; import { useAdminOverview, useEngine, usePayments } from "@/queries/admin.ts";
import { formatMinute } from "@/utils/time.ts"; import { formatMinute, toRequestIso } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts"; import { formatInt } from "@/utils/format.ts";
const router = useRouter(); const router = useRouter();
@@ -40,10 +40,10 @@ const extraPresets = computed(() => {
.filter((p) => p.kind === "subscription" && p.period_start != null) .filter((p) => p.kind === "subscription" && p.period_start != null)
.map((p) => ({ .map((p) => ({
label: `订阅 #${p.subscription_id ?? p.id} 期间`, label: `订阅 #${p.subscription_id ?? p.id} 期间`,
from: new Date((p.period_start ?? 0) * 1000).toISOString(), from: toRequestIso(new Date((p.period_start ?? 0) * 1000)),
to: new Date( to: toRequestIso(
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000, new Date((p.period_end ?? Math.floor(Date.now() / 1000)) * 1000),
).toISOString(), ),
})) }))
.slice(0, 4); .slice(0, 4);
}); });
+5 -5
View File
@@ -11,7 +11,7 @@ import PageHeader from "@/components/common/page-header.vue";
import PaymentFormModal from "@/components/admin/payment-form-modal.vue"; import PaymentFormModal from "@/components/admin/payment-form-modal.vue";
import QuotaText from "@/components/common/quota-text.vue"; import QuotaText from "@/components/common/quota-text.vue";
import { useDeletePayment, usePayments } from "@/queries/admin.ts"; import { useDeletePayment, usePayments } from "@/queries/admin.ts";
import { formatDate, formatDateTime } from "@/utils/time.ts"; import { formatDateTime, toRequestIso } from "@/utils/time.ts";
import { formatPaidAmount } from "@/utils/format.ts"; import { formatPaidAmount } from "@/utils/format.ts";
const paymentsQuery = usePayments(); const paymentsQuery = usePayments();
@@ -40,8 +40,8 @@ const columns = [
function timeText(p: PaymentItem): string { function timeText(p: PaymentItem): string {
if (p.kind === "topup") return formatDateTime(p.paid_at); if (p.kind === "topup") return formatDateTime(p.paid_at);
const start = formatDate(p.period_start); const start = formatDateTime(p.period_start);
const end = p.period_end == null ? "进行中" : formatDate(p.period_end); const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${start} ~ ${end}`; return `${start} ~ ${end}`;
} }
@@ -76,8 +76,8 @@ async function reconcile(pays: PaymentItem[]) {
pays.map((p) => p.subscription_id).filter((v): v is number => v != null), pays.map((p) => p.subscription_id).filter((v): v is number => v != null),
); );
const earliest = Math.min(...pays.map((p) => p.paid_at ?? 0), Date.now() / 1000); const earliest = Math.min(...pays.map((p) => p.paid_at ?? 0), Date.now() / 1000);
const from = new Date((earliest - 30 * 86400) * 1000).toISOString(); const from = toRequestIso(new Date((earliest - 30 * 86400) * 1000));
const to = new Date().toISOString(); const to = toRequestIso(new Date());
const observed = new Set<number>(); const observed = new Set<number>();
let page = 1; let page = 1;
for (;;) { for (;;) {
+2 -2
View File
@@ -57,7 +57,7 @@
## 4. 时间口径 ## 4. 时间口径
- **响应时刻字段:Unix 秒整数**(UTC 时刻,如 `1780000000`)——无时区歧义;本地/北京时间由展示层转换 - **响应时刻字段:Unix 秒整数**(UTC 时刻,如 `1780000000`)——无时区歧义;本地/北京时间由展示层转换
- **请求时间参数**(`from` / `to` 等):ISO 8601 UTC 字符串(便于试调);范围 **左闭右开 `[from, to)`** - **请求时间参数**(`from` / `to`、支付 `paid_at` / `period_*` 等):**ISO 8601 带时区偏移**(如 `2026-09-08T00:00:00.000+08:00`,便于人读;亦接受同绝对时刻的 `…Z`);范围 **左闭右开 `[from, to)`**
- 存储层维持 timestamptz / 上游 Unix 秒;DTO 输出统一 `toUnixSec` - 存储层维持 timestamptz / 上游 Unix 秒;DTO 输出统一 `toUnixSec`
- 快捷项(最近 7 / 30 天、本订阅期间、自定义起止)是前端预置逻辑:前端算出 `from/to` 再请求,后端无快捷参数 - 快捷项(最近 7 / 30 天、本订阅期间、自定义起止)是前端预置逻辑:前端算出 `from/to` 再请求,后端无快捷参数
@@ -82,7 +82,7 @@
## 8. 本稿拟定约定(交互设计未覆盖,请作者重点审) ## 8. 本稿拟定约定(交互设计未覆盖,请作者重点审)
1. 时间传输格式 ISO 8601 UTC 字符串(§4); 1. 时间:**请求侧** ISO 8601 带偏移(§4);**响应侧** Unix 秒整数(§4);
2. 时间范围左闭右开 `[from, to)`(§4); 2. 时间范围左闭右开 `[from, to)`(§4);
3. `quota_usd` 字符串两位小数 + 后端换算责任 + 微分取整规则(§5); 3. `quota_usd` 字符串两位小数 + 后端换算责任 + 微分取整规则(§5);
4. DTO 与表字段 snake_case 同构、无映射层(§7); 4. DTO 与表字段 snake_case 同构、无映射层(§7);
+7 -2
View File
@@ -636,12 +636,17 @@ export function createRepos(db: Db) {
}, },
keysExtra: { keysExtra: {
/** 全池数据截止 = 最慢 log_synced_at */ /** 全池数据截止 = 仍会同步的 key 中最慢 log_synced_at(停用 key 不计) */
async slowestLogSyncedAt(): Promise<Date | null> { async slowestLogSyncedAt(): Promise<Date | null> {
const rows = await db const rows = await db
.select({ t: sql<string | null>`min(${apiKeys.log_synced_at})::text` }) .select({ t: sql<string | null>`min(${apiKeys.log_synced_at})::text` })
.from(apiKeys) .from(apiKeys)
.where(sql`${apiKeys.log_synced_at} is not null`); .where(
and(
eq(apiKeys.status, "active"),
sql`${apiKeys.log_synced_at} is not null`,
),
);
const raw = rows[0]?.t; const raw = rows[0]?.t;
if (!raw) return null; if (!raw) return null;
const d = new Date(raw); const d = new Date(raw);