fix(db): 统一字符集排序规则并修复时间戳类型

- 新增 SQL 脚本统一表与列字符集为 utf8mb4_unicode_ci
- 移除建表语句冗余 COLLATE 子句,由全局配置控制
- 将挑战起止时间字段由 Date 改为 BIGINT 时间戳,避免时区与精度问题
- 补充 Winston 日志追踪挑战详情查询性能
- 数据库模块新增 charset 与 collate 全局配置,确保后续表一致性

BREAKING CHANGE: challenge.startAt/endAt 由 Date 变更为 number(毫秒时间戳),调用方需同步调整类型
This commit is contained in:
richarjiang
2025-09-29 09:59:06 +08:00
parent ae8039c9ed
commit 22fcf694a6
6 changed files with 92 additions and 13 deletions

View File

@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS t_challenges (
cta_label VARCHAR(128) NOT NULL COMMENT 'CTA 按钮文字', cta_label VARCHAR(128) NOT NULL COMMENT 'CTA 按钮文字',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB
CREATE TABLE IF NOT EXISTS t_challenge_participants ( CREATE TABLE IF NOT EXISTS t_challenge_participants (
id CHAR(36) NOT NULL PRIMARY KEY, id CHAR(36) NOT NULL PRIMARY KEY,
@@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS t_challenge_participants (
CONSTRAINT fk_challenge_participant_challenge FOREIGN KEY (challenge_id) REFERENCES t_challenges (id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_challenge_participant_challenge FOREIGN KEY (challenge_id) REFERENCES t_challenges (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_challenge_participant_user FOREIGN KEY (user_id) REFERENCES t_users (id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_challenge_participant_user FOREIGN KEY (user_id) REFERENCES t_users (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT uq_challenge_participant UNIQUE KEY (challenge_id, user_id) CONSTRAINT uq_challenge_participant UNIQUE KEY (challenge_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB
CREATE INDEX idx_challenge_participants_status_progress CREATE INDEX idx_challenge_participants_status_progress
ON t_challenge_participants (challenge_id, status, progress_value DESC, updated_at ASC); ON t_challenge_participants (challenge_id, status, progress_value DESC, updated_at ASC);
@@ -53,4 +53,4 @@ CREATE TABLE IF NOT EXISTS t_challenge_progress_reports (
CONSTRAINT fk_challenge_progress_reports_challenge FOREIGN KEY (challenge_id) REFERENCES t_challenges (id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_challenge_progress_reports_challenge FOREIGN KEY (challenge_id) REFERENCES t_challenges (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_challenge_progress_reports_user FOREIGN KEY (user_id) REFERENCES t_users (id) ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT fk_challenge_progress_reports_user FOREIGN KEY (user_id) REFERENCES t_users (id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT uq_challenge_progress_reports_day UNIQUE KEY (challenge_id, user_id, report_date) CONSTRAINT uq_challenge_progress_reports_day UNIQUE KEY (challenge_id, user_id, report_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB

View File

@@ -0,0 +1,41 @@
-- 修复字符集排序规则不一致的问题
-- 将所有相关表的字符集统一为 utf8mb4_unicode_ci
-- 检查当前表的字符集和排序规则
SELECT
TABLE_NAME,
TABLE_COLLATION,
CHARACTER_SET_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('t_users', 't_challenge_participants', 't_challenges');
-- 检查列的字符集和排序规则
SELECT
TABLE_NAME,
COLUMN_NAME,
COLLATION_NAME,
CHARACTER_SET_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('t_users', 't_challenge_participants', 't_challenges')
AND COLUMN_NAME IN ('id', 'user_id', 'challenge_id');
-- 修改表字符集和排序规则
ALTER TABLE t_users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE t_challenge_participants CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE t_challenges CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 修改特定列的字符集和排序规则(如果需要)
ALTER TABLE t_users MODIFY id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE t_challenge_participants MODIFY user_id VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE t_challenge_participants MODIFY challenge_id CHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 验证修复结果
SELECT
TABLE_NAME,
TABLE_COLLATION,
CHARACTER_SET_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('t_users', 't_challenge_participants', 't_challenges');

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException, ConflictException, Inject } from '@nestjs/common';
import { InjectModel } from '@nestjs/sequelize'; import { InjectModel } from '@nestjs/sequelize';
import { Challenge, ChallengeStatus } from './models/challenge.model'; import { Challenge, ChallengeStatus } from './models/challenge.model';
import { ChallengeParticipant, ChallengeParticipantStatus } from './models/challenge-participant.model'; import { ChallengeParticipant, ChallengeParticipantStatus } from './models/challenge-participant.model';
@@ -10,10 +10,15 @@ import { ChallengeProgressDto, RankingItemDto } from './dto/challenge-progress.d
import { fn, col, Op, UniqueConstraintError } from 'sequelize'; import { fn, col, Op, UniqueConstraintError } from 'sequelize';
import * as dayjs from 'dayjs'; import * as dayjs from 'dayjs';
import { User } from '../users/models/user.model'; import { User } from '../users/models/user.model';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger as WinstonLogger } from 'winston';
@Injectable() @Injectable()
export class ChallengesService { export class ChallengesService {
constructor( constructor(
@Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger,
@InjectModel(Challenge) @InjectModel(Challenge)
private readonly challengeModel: typeof Challenge, private readonly challengeModel: typeof Challenge,
@InjectModel(ChallengeParticipant) @InjectModel(ChallengeParticipant)
@@ -100,6 +105,13 @@ export class ChallengesService {
throw new NotFoundException('挑战不存在'); throw new NotFoundException('挑战不存在');
} }
this.winstonLogger.info('start get detail', {
context: 'getChallengeDetail',
userId,
challengeId,
});
const [participantsCount, participation] = await Promise.all([ const [participantsCount, participation] = await Promise.all([
this.participantModel.count({ this.participantModel.count({
where: { where: {
@@ -118,6 +130,15 @@ export class ChallengesService {
}), }),
]); ]);
this.winstonLogger.info('end get detail', {
context: 'getChallengeDetail',
userId,
challengeId,
participantsCount,
participation,
});
const rankingsRaw = await this.participantModel.findAll({ const rankingsRaw = await this.participantModel.findAll({
where: { where: {
challengeId, challengeId,
@@ -131,6 +152,15 @@ export class ChallengesService {
limit: 10, limit: 10,
}); });
this.winstonLogger.info('get rankingsRaw end', {
context: 'getChallengeDetail',
userId,
challengeId,
participantsCount,
participation,
rankingsRawCount: rankingsRaw.length,
});
const progress = participation const progress = participation
? this.buildChallengeProgress(participation.progressValue, participation.targetValue, challenge.progressUnit) ? this.buildChallengeProgress(participation.progressValue, participation.targetValue, challenge.progressUnit)
: undefined; : undefined;
@@ -333,7 +363,7 @@ export class ChallengesService {
}; };
} }
private computeStatus(startAt: Date, endAt: Date): ChallengeStatus { private computeStatus(startAt: number, endAt: number): ChallengeStatus {
const now = dayjs(); const now = dayjs();
const start = dayjs(startAt); const start = dayjs(startAt);
const end = dayjs(endAt); const end = dayjs(endAt);

View File

@@ -9,8 +9,8 @@ export interface ChallengeListItemDto {
durationLabel: string; durationLabel: string;
requirementLabel: string; requirementLabel: string;
status: ChallengeStatus; status: ChallengeStatus;
startAt: Date; startAt: number;
endAt: Date; endAt: number;
participantsCount: number; participantsCount: number;
rankingDescription: string | null; rankingDescription: string | null;
highlightTitle: string; highlightTitle: string;

View File

@@ -34,18 +34,18 @@ export class Challenge extends Model {
declare image: string; declare image: string;
@Column({ @Column({
type: DataType.DATE, type: DataType.BIGINT,
allowNull: false, allowNull: false,
comment: '挑战开始时间', comment: '挑战开始时间(时间戳)',
}) })
declare startAt: Date; declare startAt: number;
@Column({ @Column({
type: DataType.DATE, type: DataType.BIGINT,
allowNull: false, allowNull: false,
comment: '挑战结束时间', comment: '挑战结束时间(时间戳)',
}) })
declare endAt: Date; declare endAt: number;
@Column({ @Column({
type: DataType.STRING(128), type: DataType.STRING(128),

View File

@@ -13,6 +13,14 @@ import { ConfigService } from '@nestjs/config';
username: configService.get('DB_USERNAME'), username: configService.get('DB_USERNAME'),
password: configService.get('DB_PASSWORD'), password: configService.get('DB_PASSWORD'),
database: configService.get('DB_DATABASE'), database: configService.get('DB_DATABASE'),
dialectOptions: {
charset: 'utf8mb4',
collate: 'utf8mb4_0900_ai_ci',
},
define: {
charset: 'utf8mb4',
collate: 'utf8mb4_0900_ai_ci',
},
autoLoadModels: true, autoLoadModels: true,
synchronize: true, synchronize: true,
}), }),