修复批量已读事务原子性并补充回滚测试
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-17 00:24:18 +08:00
parent 6bcdf7c59c
commit 3ae8ecb11c
3 changed files with 158 additions and 6 deletions

View File

@ -25,12 +25,10 @@ func MarkRead(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
return
}
for _, id := range reqData.IDs {
if _, err := detail.GetEmailDetail(ctx, id, true); err != nil {
log.WithContext(ctx).WithError(err).Error("mark email read failed")
response.NewErrorResponse(response.ServerError, "更新邮件失败", "").FPrint(w)
return
}
if err := detail.MarkEmailsRead(ctx, reqData.IDs); err != nil {
log.WithContext(ctx).WithError(err).Error("mark email read failed")
response.NewErrorResponse(response.ServerError, "更新邮件失败", "").FPrint(w)
return
}
response.NewSuccessResponse("success").FPrint(w)

View File

@ -0,0 +1,99 @@
package detail
import (
"path/filepath"
"testing"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/context"
)
func initBatchReadTestDB(t *testing.T) {
t.Helper()
oldConfig := config.Instance
oldDB := db.Instance
config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(t.TempDir(), "batch-read.db")}
if err := db.Init(""); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if db.Instance != nil {
_ = db.Instance.Close()
}
db.Instance = oldDB
config.Instance = oldConfig
})
}
func TestMarkEmailsReadRejectsPartiallyOwnedBatch(t *testing.T) {
initBatchReadTestDB(t)
if _, err := db.Instance.Insert(&models.UserEmail{UserID: 1, EmailID: 101}, &models.UserEmail{UserID: 2, EmailID: 202}); err != nil {
t.Fatal(err)
}
err := MarkEmailsRead(&context.Context{UserID: 1}, []int{101, 202})
if err == nil {
t.Fatal("partially owned batch was accepted")
}
var relation models.UserEmail
if has, loadErr := db.Instance.Where("user_id = ? and email_id = ?", 1, 101).Get(&relation); loadErr != nil || !has {
t.Fatalf("load owned relation: has=%v err=%v", has, loadErr)
}
if relation.IsRead != 0 {
t.Fatalf("owned relation changed after rejected batch: is_read=%d", relation.IsRead)
}
}
func TestMarkEmailsReadRollsBackWhenUpdateFails(t *testing.T) {
initBatchReadTestDB(t)
if _, err := db.Instance.Insert(&models.UserEmail{UserID: 1, EmailID: 301}, &models.UserEmail{UserID: 1, EmailID: 302}); err != nil {
t.Fatal(err)
}
trigger := "CREATE TRIGGER fail_batch_read BEFORE UPDATE OF is_read ON user_email " +
"WHEN NEW.email_id = 302 BEGIN SELECT RAISE(ABORT, 'forced batch read failure'); END"
if _, err := db.Instance.Exec(trigger); err != nil {
t.Fatal(err)
}
err := MarkEmailsRead(&context.Context{UserID: 1}, []int{301, 302})
if err == nil {
t.Fatal("batch read update unexpectedly succeeded")
}
var relations []models.UserEmail
if err := db.Instance.Where("user_id = ?", 1).OrderBy("email_id").Find(&relations); err != nil {
t.Fatal(err)
}
if len(relations) != 2 || relations[0].IsRead != 0 || relations[1].IsRead != 0 {
t.Fatalf("batch read was partially committed: %+v", relations)
}
}
func TestMarkEmailsReadUpdatesOwnedBatchOnce(t *testing.T) {
initBatchReadTestDB(t)
if _, err := db.Instance.Insert(&models.UserEmail{UserID: 1, EmailID: 401}, &models.UserEmail{UserID: 1, EmailID: 402, IsRead: 1}); err != nil {
t.Fatal(err)
}
if err := MarkEmailsRead(&context.Context{UserID: 1}, []int{401, 401, 402}); err != nil {
t.Fatal(err)
}
var unread int64
if unread, _ = db.Instance.Where("user_id = ? and is_read = 0", 1).Count(&models.UserEmail{}); unread != 0 {
t.Fatalf("unread relation count = %d", unread)
}
}
func TestMarkEmailsReadRejectsInvalidID(t *testing.T) {
initBatchReadTestDB(t)
if err := MarkEmailsRead(&context.Context{UserID: 1}, []int{0}); err == nil {
t.Fatal("invalid email id was accepted")
}
if err := MarkEmailsRead(&context.Context{UserID: 1}, nil); err == nil {
t.Fatal("empty email id list was accepted")
}
}

View File

@ -13,6 +13,7 @@ import (
"git.pcux.cn/xuans/Smail/utils/errors"
"git.pcux.cn/xuans/Smail/utils/query"
"strings"
"xorm.io/xorm"
)
import . "xorm.io/builder"
@ -100,6 +101,60 @@ func MakeRead(ctx *context.Context, emailID int, read bool) error {
}
return nil
}
func MarkEmailsRead(ctx *context.Context, emailIDs []int) error {
if ctx == nil || ctx.UserID <= 0 || db.Instance == nil {
return errors.New("Not authorized")
}
uniqueIDs := make([]int, 0, len(emailIDs))
seenIDs := make(map[int]struct{}, len(emailIDs))
for _, emailID := range emailIDs {
if emailID <= 0 {
return errors.New("invalid email id")
}
if _, exists := seenIDs[emailID]; exists {
continue
}
seenIDs[emailID] = struct{}{}
uniqueIDs = append(uniqueIDs, emailID)
}
if len(uniqueIDs) == 0 {
return errors.New("empty email id list")
}
placeholders, idArgs, ok := query.IntList(uniqueIDs, 10000)
if !ok {
return errors.New("invalid email id list")
}
return db.Transaction(db.Instance, func(session *xorm.Session) error {
var relations []models.UserEmail
selectArgs := append([]any{ctx.UserID}, idArgs...)
selectSQL := fmt.Sprintf("SELECT id,email_id,is_read FROM user_email WHERE user_id = ? AND email_id IN (%s)", placeholders)
if err := session.SQL(selectSQL, selectArgs...).Find(&relations); err != nil {
return fmt.Errorf("load email ownership for read update: %w", err)
}
ownedIDs := make(map[int]struct{}, len(relations))
for _, relation := range relations {
ownedIDs[relation.EmailID] = struct{}{}
}
for _, emailID := range uniqueIDs {
if _, owned := ownedIDs[emailID]; !owned {
return fmt.Errorf("email %d is not owned by current user", emailID)
}
}
updateArgs := append([]any{ctx.UserID}, idArgs...)
updateSQL := fmt.Sprintf("user_id = ? AND email_id IN (%s) AND is_read <> 1", placeholders)
if _, err := session.Table("user_email").Where(updateSQL, updateArgs...).Cols("is_read").Update(map[string]any{"is_read": 1}); err != nil {
return fmt.Errorf("update email read state: %w", err)
}
return nil
})
}
func FindUE(ctx *context.Context, groupName string, req list.ImapListReq, uid bool) ([]models.UserEmail, error) {
var ue []models.UserEmail
if uid {