This commit is contained in:
parent
6bcdf7c59c
commit
3ae8ecb11c
@ -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)
|
||||
|
||||
|
||||
99
backend/services/detail/batch_read_test.go
Normal file
99
backend/services/detail/batch_read_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user