Files
shaguabijia-admin-web/src/lib/format.ts
T
linkeyu 549c9bdfad 修复:统一领券与比价广告数百分位口径 (#92)
## 改动说明

- 将单次领券、比价广告数从线性插值百分位改为 nearest-rank 离散百分位
- 百分位结果始终取自真实观测样本,不再出现 13.5 条这类小数广告数
- 指标展示改为整数

## 线上数据复算

2026-07-24 正式业务口径:

- 领券 P5/P50/P95:`1/7/13.5` 修正为 `1/7/15`
- 比价 P5/P50/P95:`1/2/8.3` 修正为 `1/2/9`

## 验证

- 线上数据完整分组复算通过
- 空样本、单样本边界校验通过
- `npm run build` 通过(含 TypeScript 类型检查)

---------

Co-authored-by: guke <guke@wonderable.ai>
Co-authored-by: linkeyu <798648091@qq.com>
Reviewed-on: #92
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-28 13:50:40 +08:00

74 lines
3.1 KiB
TypeScript

// 全站统一的金额 / 时间格式化。
//
// 后端有两种时间口径,序列化后从字符串无法区分,必须按字段语义选对函数:
// 1. UTC 口径:`server_default=func.now()` 的字段(用户/提现单/审计日志/广告发奖记录)。
// 入库是 UTC,SQLite 下序列化不带时区、Postgres 下带 +00:00。统一当 UTC 解析后转北京显示。
// 2. 北京 wall-clock 口径:钱包流水(coin_transaction / cash_transaction)。入库即
// `datetime.now(CN_TZ).replace(tzinfo=None)`,字面就是北京时间且无时区,**不能再做时区换算**。
// 历史上各页面混用 `new Date().toLocaleString`(按本地解析)与 `apiTime`(补 Z 当 UTC),
// 导致提现详情里现金流水时间快 8 小时。此模块收口,按口径区分。
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
const TZ = 'Asia/Shanghai';
/** 金额:分 → ¥元(两位小数)。 */
export const yuan = (cents: number) => `¥${(cents / 100).toFixed(2)}`;
/**
* 线性插值分位数。
* 耗时等整数指标默认四舍五入;计数分位需要保留插值小数时传 round=false。
*/
export function percentile(values: readonly number[], q: number, round = true): number | null {
if (!values.length) return null;
const sorted = [...values].sort((a, b) => a - b);
const idx = (sorted.length - 1) * q;
const lo = Math.floor(idx);
const hi = Math.min(lo + 1, sorted.length - 1);
const result = sorted[lo] * (hi - idx) + sorted[hi] * (idx - lo);
return round ? Math.round(result) : result;
}
/**
* 离散计数分位数(nearest-rank)。
* 广告条数等不可拆分的计数不做线性插值,结果始终取自真实样本。
*/
export function nearestRankPercentile(values: readonly number[], q: number): number | null {
if (!values.length) return null;
const sorted = [...values].sort((a, b) => a - b);
const quantile = Math.min(1, Math.max(0, q));
const rank = Math.max(1, Math.ceil(quantile * sorted.length));
return sorted[rank - 1];
}
const hasTz = (v: string) => /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v);
/** 把 UTC 口径字符串解析为带时区的 dayjs(无时区后缀的补 Z 当 UTC)。 */
export const utcDayjs = (v: string) => dayjs(hasTz(v) ? v : `${v}Z`);
/** UTC 口径时间 → 北京时间显示(用户/提现单/审计/广告发奖记录)。 */
export function formatUtcTime(v?: string | null, fmt = 'YYYY-MM-DD HH:mm'): string {
if (!v) return '-';
return utcDayjs(v).tz(TZ).format(fmt);
}
/** UTC 口径相对时间(如「3 分钟前」)。 */
export function utcFromNow(v?: string | null): string {
if (!v) return '-';
return utcDayjs(v).fromNow();
}
/** 北京 wall-clock 口径时间 → 原样格式化,不做时区换算(钱包金币/现金流水)。 */
export function formatWallTime(v?: string | null, fmt = 'YYYY-MM-DD HH:mm'): string {
if (!v) return '-';
return dayjs(v).format(fmt);
}