Files
cpdaily_xiaozhushou/backed/internal/handler/softdelete_probe_test.go
T
becomer ac743eda88
ci/woodpecker/tag/build Pipeline was successful
v1.2.52:执行日志软删除改造——用户删除仅标记 deleted_at(前台隐藏、任务状态不受影响),后台统计与明细计入已删除(标注「已删除」)——① 动机:日志删除原为物理删除,会连带从后台统计中消失,导致「已执行累计/今日统计」凭空减少,且删除当天成功日志会让任务被跨天重置逻辑误判为未执行、出现重复打卡;② 模型:CpTaskLog / CpLeaveLog 增加 DeletedAt gorm.DeletedAt(带索引),AutoMigrate 自动加列,存量数据 deleted_at 为 NULL 视为未删除,无需数据迁移;③ 前台:GORM 默认作用域自动过滤软删除,用户端日志列表(/task/logs、/leave/logs)与删除接口(DB.Delete 自动转为 UPDATE deleted_at)无需改动即符合预期;④ 任务完成状态:ListTasks 的「当天是否存在成功日志」跨天重置查询改用 Unscoped,删除当天日志不再触发状态回退(否则会重复打卡);⑤ 后台统计:已执行累计、今日状态、今日统计卡片(执行/成功总数)全部改用 Unscoped 并并入请假/销假(cp_leave_logs),口径统一为「签到+查寝+请假+销假」;⑥ 后台明细:AdminListTaskLogs 由单表查询改为两表 UNION(同一条常量 adminLogUnionSQL,事务探针复用),新增 deleted(已删除标注)与 source(1=任务 2=请假销假)字段,请假/销假映射 task_type=leave_apply/leave_report;⑦ 修复 UNION 真实报错:两表分属不同时期建表,字符串列 collation 不一致(cp_task_logs=utf8mb4_unicode_ci / cp_leave_logs=utf8mb4_general_ci),直接 UNION 报 MySQL 1271 Illegal mix of collations,已对请假侧字符串列显式 COLLATE utf8mb4_unicode_ci;⑧ 新增事务级探针 TestSoftDeleteProbe(默认跳过,PROBE_DB=1 时连真实库执行并整体回滚,不改动任何持久数据):覆盖前台隐藏、状态不受影响、后台计入、UNION 标注/deleted/source/success 过滤分支;⑨ 前端:AdminUsers.vue 日志明细类型标签补「请假/销假」、已删除记录追加灰色「已删除」徽标、列表 key 由 id 改为 source-id(两表 id 独立自增会撞键),api/admin.ts 类型同步;⑩ 验证:go build、go vet、go test ./...、PROBE_DB=1 事务探针、vue-tsc --noEmit、npm run build 全过
2026-09-15 18:38:16 +08:00

179 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
// 事务级软删除探针:验证「用户删除日志 → 仅软删除」的四处语义。
// 默认跳过;仅在显式设置 PROBE_DB=1 时连接真实数据库执行(需要 backed/config.toml)。
// 全过程在单个事务内进行并最终回滚,不写入任何持久数据(AutoMigrate 除外,与 server 启动行为一致)。
//
// go test ./internal/handler -run TestSoftDeleteProbe -v # 需 PROBE_DB=1
//
// 覆盖点:
// 1. 前台列表(GORM 默认作用域)不展示已删除记录
// 2. 任务完成状态判定(Unscoped 查当天成功日志)不受删除影响
// 3. 后台统计(已执行累计 / 今日状态 / 今日卡片)计入已删除记录
// 4. 后台明细 UNION SQL(adminLogUnionSQL,与 handler 同一常量)含已删除记录并正确标注 deleted/source
import (
"fmt"
"os"
"testing"
"time"
"cpdaily-new/internal/config"
"cpdaily-new/internal/database"
"cpdaily-new/internal/model"
)
func TestSoftDeleteProbe(t *testing.T) {
if os.Getenv("PROBE_DB") != "1" {
t.Skip("跳过:需要 PROBE_DB=1 且可连接数据库")
}
if err := config.Load("../../config.toml"); err != nil {
t.Fatalf("加载配置失败: %v", err)
}
database.Init()
tx := database.DB.Begin()
if tx.Error != nil {
t.Fatalf("开启事务失败: %v", tx.Error)
}
defer tx.Rollback() // 无论成败一律回滚,探针不留痕
// ---------- 构造数据(全部在事务内,ID 由自增分配) ----------
user := model.SysUser{
Username: fmt.Sprintf("sdprobe_%d", time.Now().UnixNano()),
Password: "probe",
SchoolID: "probe",
StudentID: "probe",
InviteCode: fmt.Sprintf("p%09d", time.Now().UnixNano()%1000000000), // 避免唯一索引冲突
}
if err := tx.Create(&user).Error; err != nil {
t.Fatalf("创建探针用户失败: %v", err)
}
uid := user.ID
taskLog := model.CpTaskLog{
CpTaskID: 1, UserID: uid, TaskType: "sign",
Title: "探针签到", RealTitle: "探针签到", IsSuccess: true,
}
leaveLog := model.CpLeaveLog{
UserID: uid, LeaveID: "probe_leave", Title: "探针请假",
SchoolName: "探针学校", Action: model.LeaveActionApply, IsSuccess: true,
}
if err := tx.Create(&taskLog).Error; err != nil {
t.Fatalf("创建任务日志失败: %v", err)
}
if err := tx.Create(&leaveLog).Error; err != nil {
t.Fatalf("创建请假日志失败: %v", err)
}
// 用户端「删除」= GORM 软删除(handler 中即 database.DB.Delete(&log))
if err := tx.Delete(&taskLog).Error; err != nil {
t.Fatalf("软删除任务日志失败: %v", err)
}
if err := tx.Delete(&leaveLog).Error; err != nil {
t.Fatalf("软删除请假日志失败: %v", err)
}
// 回读确认 deleted_at 已置位(而非物理删除)
var rawCount int64
if err := tx.Unscoped().Model(&model.CpTaskLog{}).Where("id = ? AND deleted_at IS NOT NULL", taskLog.ID).Count(&rawCount).Error; err != nil || rawCount != 1 {
t.Fatalf("任务日志未软删除: count=%d err=%v", rawCount, err)
}
if err := tx.Unscoped().Model(&model.CpLeaveLog{}).Where("id = ? AND deleted_at IS NOT NULL", leaveLog.ID).Count(&rawCount).Error; err != nil || rawCount != 1 {
t.Fatalf("请假日志未软删除: count=%d err=%v", rawCount, err)
}
// ---------- 1. 前台列表不可见(GORM 默认作用域过滤 deleted_at) ----------
var frontTask []model.CpTaskLog
tx.Where("user_id = ?", uid).Order("created_at DESC").Limit(10).Find(&frontTask)
if len(frontTask) != 0 {
t.Errorf("[1] 前台任务日志应不可见已删除记录, got %d 条", len(frontTask))
}
var frontLeave []model.CpLeaveLog
tx.Where("user_id = ?", uid).Order("created_at DESC").Limit(10).Find(&frontLeave)
if len(frontLeave) != 0 {
t.Errorf("[1] 前台请假日志应不可见已删除记录, got %d 条", len(frontLeave))
}
// ---------- 2. 任务完成状态不受删除影响(task.go ListTasks 的跨天重置查询) ----------
now := time.Now()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var todaySuccessIDs []uint
tx.Unscoped().Model(&model.CpTaskLog{}).
Where("user_id = ? AND is_success = 1 AND created_at >= ?", uid, startOfToday).
Distinct("cp_task_id").Pluck("cp_task_id", &todaySuccessIDs)
if len(todaySuccessIDs) != 1 || todaySuccessIDs[0] != 1 {
t.Errorf("[2] 删除当天成功日志后任务完成状态被误重置: IDs=%v", todaySuccessIDs)
}
// ---------- 3. 后台统计计入已删除 ----------
var taskSuccess, leaveSuccess int64
tx.Unscoped().Model(&model.CpTaskLog{}).Where("user_id = ? AND is_success = 1", uid).Count(&taskSuccess)
tx.Unscoped().Model(&model.CpLeaveLog{}).Where("user_id = ? AND is_success = 1", uid).Count(&leaveSuccess)
if taskSuccess != 1 || leaveSuccess != 1 {
t.Errorf("[3] 后台已执行累计应计入已删除: task=%d leave=%d", taskSuccess, leaveSuccess)
}
var todayTask, todayLeave int64
tx.Unscoped().Model(&model.CpTaskLog{}).Where("user_id = ? AND created_at >= ?", uid, startOfToday).Count(&todayTask)
tx.Unscoped().Model(&model.CpLeaveLog{}).Where("user_id = ? AND created_at >= ?", uid, startOfToday).Count(&todayLeave)
if todayTask != 1 || todayLeave != 1 {
t.Errorf("[3] 今日统计卡片应计入已删除: task=%d leave=%d", todayTask, todayLeave)
}
// ---------- 4. 后台明细 UNION(同一 adminLogUnionSQL 常量) ----------
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
dayEnd := dayStart.AddDate(0, 0, 1)
type combinedRow struct {
ID uint
UserID uint
TaskType string
Deleted bool
Source int
IsSuccess bool
}
var rows []combinedRow
if err := tx.Raw(adminLogUnionSQL, dayStart, dayEnd, dayStart, dayEnd).Scan(&rows).Error; err != nil {
t.Fatalf("[4] UNION 查询失败: %v", err)
}
var mine []combinedRow
for _, r := range rows {
if r.UserID == uid {
mine = append(mine, r)
}
}
if len(mine) != 2 {
t.Fatalf("[4] UNION 应返回 2 条(任务+请假,均含已删除), got %d", len(mine))
}
sourceSeen := map[int]bool{}
taskTypeSeen := map[string]bool{}
for _, r := range mine {
if !r.Deleted {
t.Errorf("[4] 已删除记录在明细中未标注 deleted: %+v", r)
}
sourceSeen[r.Source] = true
taskTypeSeen[r.TaskType] = true
}
if !sourceSeen[1] || !sourceSeen[2] {
t.Errorf("[4] source 标注缺失: %v", sourceSeen)
}
if !taskTypeSeen["sign"] || !taskTypeSeen["leave_apply"] {
t.Errorf("[4] task_type 映射缺失: %v", taskTypeSeen)
}
// 用户端状态筛选(success=1)走 UNION 包裹分支,确认占位符与包裹 SQL 兼容
var successRows []combinedRow
tx.Raw("SELECT * FROM ("+adminLogUnionSQL+") t WHERE t.is_success = 1", dayStart, dayEnd, dayStart, dayEnd).Scan(&successRows)
found := 0
for _, r := range successRows {
if r.UserID == uid {
found++
}
}
if found != 2 {
t.Errorf("[4] success 过滤分支应保留 2 条已删除成功记录, got %d", found)
}
t.Log("软删除探针全部通过:前台隐藏 / 状态不受影响 / 后台计入 / 明细标注")
// defer 回滚,探针不落库
}