diff --git a/apis/hole/apis.go b/apis/hole/apis.go index d76df5e..8e616a7 100644 --- a/apis/hole/apis.go +++ b/apis/hole/apis.go @@ -341,16 +341,18 @@ func CreateHole(c *fiber.Ctx) error { } hole := Hole{ - Floors: Floors{{ - UserID: user.ID, - Content: body.Content, - SpecialTag: body.SpecialTag, - IsMe: true, - IsSensitive: !sensitiveResp.Pass, - SensitiveDetail: sensitiveResp.Detail, - }}, - UserID: user.ID, - DivisionID: divisionID, + BaseHole: BaseHole{ + Floors: Floors{{ + UserID: user.ID, + Content: body.Content, + SpecialTag: body.SpecialTag, + IsMe: true, + IsSensitive: !sensitiveResp.Pass, + SensitiveDetail: sensitiveResp.Detail, + }}, + UserID: user.ID, + DivisionID: divisionID, + }, } err = hole.Create(DB, user, body.ToName(), c) if err != nil { @@ -410,16 +412,19 @@ func CreateHoleOld(c *fiber.Ctx) error { // create hole hole := Hole{ - Floors: Floors{{ - UserID: user.ID, - Content: body.Content, - SpecialTag: body.SpecialTag, - IsMe: true, - IsSensitive: !sensitiveResp.Pass, - SensitiveDetail: sensitiveResp.Detail, - }}, - UserID: user.ID, - DivisionID: body.DivisionID, + BaseHole: BaseHole{ + Floors: Floors{{ + UserID: user.ID, + Content: body.Content, + SpecialTag: body.SpecialTag, + IsMe: true, + IsSensitive: !sensitiveResp.Pass, + SensitiveDetail: sensitiveResp.Detail, + }}, + UserID: user.ID, + DivisionID: body.DivisionID, + }, + } err = hole.Create(DB, user, body.ToName(), c) if err != nil { @@ -677,7 +682,7 @@ func HideHole(c *fiber.Ctx) error { var hole Hole hole.ID = holeID - result := DB.Model(&hole).Select("Hidden").Omit("UpdatedAt").Updates(Hole{Hidden: true}) + result := DB.Model(&hole).Select("Hidden").Omit("UpdatedAt").Updates(Hole{BaseHole:BaseHole{Hidden: true}}) if result.RowsAffected == 0 { return gorm.ErrRecordNotFound } diff --git a/apis/hole/routes.go b/apis/hole/routes.go index cafc83d..e48af29 100644 --- a/apis/hole/routes.go +++ b/apis/hole/routes.go @@ -20,4 +20,7 @@ func RegisterRoutes(app fiber.Router) { app.Put("/holes/:id", ModifyHole) app.Delete("/holes/:id", HideHole) app.Delete("/holes/:id/_force", DeleteHole) + + // V2 + app.Get("/v2/holes/:id", GetHole) } diff --git a/benchmarks/init.go b/benchmarks/init.go index f68ed96..6163bed 100644 --- a/benchmarks/init.go +++ b/benchmarks/init.go @@ -51,11 +51,13 @@ func init() { } return nowTags } - holes = append(holes, &Hole{ - ID: i + 1, - UserID: 1, - DivisionID: rand.Intn(DIVISION_MAX) + 1, - Tags: generateTag(), + holes = append(holes, &Hole{ + BaseHole: BaseHole{ + ID: i + 1, + UserID: 1, + DivisionID: rand.Intn(DIVISION_MAX) + 1, + Tags: generateTag(), + }, }) } diff --git a/models/hole.go b/models/hole.go index 45c2b91..d4248d8 100644 --- a/models/hole.go +++ b/models/hole.go @@ -17,9 +17,11 @@ import ( "treehole_next/utils" ) -type Hole struct { +type Hole = HoleV1 + +type BaseHole struct { /// saved fields - ID int `json:"id" gorm:"primaryKey"` + ID int `json:"id" gorm:"primaryKey;"` CreatedAt time.Time `json:"time_created" gorm:"not null;index:idx_hole_div_cre,priority:2,sort:desc"` UpdatedAt time.Time `json:"time_updated" gorm:"not null;index:idx_hole_div_upd,priority:2,sort:desc"` DeletedAt gorm.DeletedAt `json:"time_deleted,omitempty" gorm:"index"` @@ -54,7 +56,7 @@ type Hole struct { Tags Tags `json:"tags" gorm:"many2many:hole_tags;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` // 楼层列表 - Floors Floors `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + Floors Floors `json:"-" gorm:"foreignKey:HoleID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` // 匿名映射表 Mapping Users `json:"-" gorm:"many2many:anonyname_mapping;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` @@ -66,6 +68,11 @@ type Hole struct { // 兼容旧版 id HoleID int `json:"hole_id" gorm:"-:all"` +} + + +type HoleV1 struct { + BaseHole // 返回给前端的楼层列表,包括首楼、尾楼和预加载的前 n 个楼层 HoleFloor struct { @@ -73,13 +80,14 @@ type Hole struct { LastFloor *Floor `json:"last_floor"` // 尾楼 Floors Floors `json:"prefetch"` // 预加载的楼层 } `json:"floors" gorm:"-:all"` + } -func (hole *Hole) GetID() int { +func (hole *BaseHole) GetID() int { return hole.ID } -func (hole *Hole) CacheName() string { +func (hole *BaseHole) CacheName() string { return fmt.Sprintf("hole_%d", hole.ID) } @@ -97,7 +105,7 @@ func IsHolesExist(tx *gorm.DB, holeID []int) bool { const HoleCacheExpire = time.Minute * 10 -func loadTags(holes Holes) (err error) { +func (holes Holes)loadTags() error { if len(holes) == 0 { return nil } @@ -107,7 +115,7 @@ func loadTags(holes Holes) (err error) { } var holeTags HoleTags - err = DB.Where("hole_id in ?", holeIDs).Find(&holeTags).Error + err := DB.Where("hole_id in ?", holeIDs).Find(&holeTags).Error if err != nil { return err } @@ -144,7 +152,7 @@ func loadTags(holes Holes) (err error) { return nil } -func loadFloors(holes Holes) error { +func (holes Holes) loadFloors() error { if len(holes) == 0 { return nil } @@ -288,12 +296,12 @@ func (holes Holes) Preprocess(c *fiber.Ctx) error { } func UpdateHoleCache(holes Holes) (err error) { - err = loadFloors(holes) + err = holes.loadFloors() if err != nil { return } - err = loadTags(holes) + err = holes.loadTags() if err != nil { return } @@ -351,7 +359,7 @@ func (holes Holes) MakeQuerySet(offset common.CustomTime, size int, order string // set hole.HoleFloor from hole.Floors or hole.HoleFloor.Floors // if Floors is not empty, set HoleFloor.Floors from Floors, in case loading from database // if Floors is empty, set HoleFloor.Floors from HoleFloor.Floors, in case loading from cache -func (hole *Hole) SetHoleFloor() { +func (hole *HoleV1) SetHoleFloor() { if len(hole.Floors) != 0 { holeFloorSize := len(hole.Floors) @@ -380,7 +388,7 @@ func (hole *Hole) SetHoleFloor() { //hole.HoleFloor.LastFloor.SetDefaults(c) } -func (hole *Hole) Create(tx *gorm.DB, user *User, tagNames []string, c *fiber.Ctx) (err error) { +func (hole *HoleV1) Create(tx *gorm.DB, user *User, tagNames []string, c *fiber.Ctx) (err error) { // Create hole.Tags, in different sql session hole.Tags, err = FindOrCreateTags(tx, user, tagNames) if err != nil { @@ -453,12 +461,12 @@ func (hole *Hole) Create(tx *gorm.DB, user *User, tagNames []string, c *fiber.Ct return utils.SetCache(hole.CacheName(), hole, HoleCacheExpire) } -func (hole *Hole) AfterCreate(_ *gorm.DB) (err error) { +func (hole *BaseHole) AfterCreate(_ *gorm.DB) (err error) { hole.HoleID = hole.ID return nil } -func (hole *Hole) AfterFind(_ *gorm.DB) (err error) { +func (hole *BaseHole) AfterFind(_ *gorm.DB) (err error) { hole.HoleID = hole.ID return nil } @@ -473,7 +481,7 @@ func (holes Holes) RemoveIf(delCondition func(*Hole) bool) Holes { return result } -func (hole *Hole) HoleHook() { +func (hole *HoleV1) HoleHook() { if hole == nil { return } diff --git a/models/holeV2.go b/models/holeV2.go new file mode 100644 index 0000000..35f1e42 --- /dev/null +++ b/models/holeV2.go @@ -0,0 +1,211 @@ +package models + +import ( + "fmt" + "time" + + "github.com/gofiber/fiber/v2" + "golang.org/x/exp/slices" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "gorm.io/plugin/dbresolver" + + "treehole_next/config" + "treehole_next/utils" + +) + +type HoleV2 struct { + BaseHole + + PrefetchFloors Floors `json:"prefetch_floors" gorm:"-:all"` +} + +func (hole *HoleV2) SetHoleFloor() { + if len(hole.Floors) != 0 { + holeFloorSize := len(hole.Floors) + + if holeFloorSize <= config.Config.HoleFloorSize { + hole.PrefetchFloors = hole.Floors + } else { + hole.PrefetchFloors = hole.Floors[0 : holeFloorSize - 1] + } + } else if len(hole.PrefetchFloors) != 0 { + hole.Floors = hole.PrefetchFloors + } +} + +type HolesV2 []*HoleV2 + +func (holes HolesV2) loadFloors() error { + if len(holes) == 0 { + return nil + } + holeIDs := utils.Models2IDSlice(holes) + + var floors Floors + + err := DB. + Raw( + // using file sort + `SELECT * FROM (? UNION ?) f ORDER BY hole_id, ranking`, + // use index(idx_hole_ranking), type range, use MRR + DB.Model(&Floor{}).Where("hole_id in ? and ranking < ?", holeIDs, config.Config.HoleFloorSize), + + // UNION, remove duplications + // use index(idx_hole_ranking), type eq_ref + DB.Model(&Floor{}).Where( + "(hole_id, ranking) in (?)", + // use index(PRIMARY), type range + DB.Model(&BaseHole{}).Select("id", "reply").Where("id in ?", holeIDs), + ), + ).Scan(&floors).Error + if err != nil { + return err + } + if len(floors) == 0 { + return nil + } + + /* + Bind floors to hole. + Note that floor is grouped by hole_id in hole_id asc order + and hole is in random order, so we have to find hole_id those floors + belong to both at the beginning and after floor group has changed. + To bind, we use two pointers. Binding occurs when the floor's hole_id + has changed, or when the floor is the last floor. + The complexity is O(m*n), where m is the number of holes and + n is the number of floors. Given that m is relatively small, + the complexity is acceptable. + */ + var left, right int + index := slices.IndexFunc(holes, func(hole *HoleV2) bool { + return hole.ID == floors[0].HoleID + }) + for _, floor := range floors { + if floor.HoleID != holes[index].ID { + holes[index].Floors = floors[left:right] + left = right + index = slices.IndexFunc(holes, func(hole *HoleV2) bool { + return hole.ID == floor.HoleID + }) + } + right++ + } + holes[index].Floors = floors[left:right] + + for _, hole := range holes { + hole.SetHoleFloor() + } + + return nil +} + +func (hole *HoleV2) Create(tx *gorm.DB, user *User, tagNames []string, c *fiber.Ctx) (err error) { + // Create hole.Tags, in different sql session + hole.Tags, err = FindOrCreateTags(tx, user, tagNames) + if err != nil { + return err + } + + var firstFloor = hole.Floors[0] + + // Find floor.Mentions, in different sql session + firstFloor.Mention, err = LoadFloorMentions(tx, firstFloor.Content) + + err = tx.Clauses(dbresolver.Write).Transaction(func(tx *gorm.DB) error { + // Create hole + err = tx.Omit(clause.Associations).Create(&hole).Error + if err != nil { + return err + } + firstFloor.HoleID = hole.ID + + // Create hole_tags association only + err = tx.Omit("Tags.*", "UpdatedAt").Select("Tags").Save(&hole).Error + if err != nil { + return err + } + + // Update tag temperature + err = tx.Model(&hole.Tags).Update("temperature", gorm.Expr("temperature + 1")).Error + if err != nil { + return err + } + + // New anonyname + firstFloor.Anonyname, err = NewAnonyname(tx, hole.ID, hole.UserID) + if err != nil { + return err + } + + // Create floor, set floor_mention association in AfterCreate hook + return tx.Omit(clause.Associations).Create(&firstFloor).Error + }) + // transaction commit here + if err != nil { + return err + } + + // set hole.HoleFloor + hole.SetHoleFloor() + + // half preprocess hole.Floor + err = firstFloor.SetDefaults(c) + if err != nil { + return err + } + + // index + if !firstFloor.Sensitive() { + go FloorIndex(FloorModel{ + ID: firstFloor.ID, + UpdatedAt: time.Now(), + Content: firstFloor.Content, + }) + } else { + firstFloor.SendSensitive(tx) + // firstFloor.Content = "" + } + + hole.HoleHook() + + // store into cache + return utils.SetCache(hole.CacheName(), hole, HoleCacheExpire) +} + +func (hole *HoleV2) HoleHook() { + if hole == nil { + return + } + notifyMessage := fmt.Sprintf("#%d\n", hole.ID) + + if hole.DivisionID == 4 { + go utils.NotifyQQ(&utils.BotMessage{ + MessageType: utils.MessageTypePrivate, + UserID: config.Config.QQBotUserID, + Message: notifyMessage, + }) + go utils.NotifyFeishu(&utils.FeishuMessage{ + MsgType: "text", + Content: notifyMessage, + }) + } + + tagToGroup := map[string]*int64{ + "@物理大神": config.Config.QQBotPhysicsGroupID, + "@码上辅导": config.Config.QQBotCodingGroupID, + } + + for _, tag := range hole.Tags { + if tag != nil { + if groupID, ok := tagToGroup[tag.Name]; ok { + go utils.NotifyQQ(&utils.BotMessage{ + MessageType: utils.MessageTypeGroup, + GroupID: groupID, + Message: notifyMessage, + }) + } + } + } +} \ No newline at end of file diff --git a/models/init.go b/models/init.go index 8fefa66..f2fd224 100644 --- a/models/init.go +++ b/models/init.go @@ -3,7 +3,7 @@ package models import ( "os" "time" - + "strings" "github.com/rs/zerolog/log" "treehole_next/config" @@ -22,6 +22,7 @@ var DB *gorm.DB var gormConfig = &gorm.Config{ NamingStrategy: schema.NamingStrategy{ SingularTable: true, // use singular table name, table for `User` would be `user` with this option enabled + NameReplacer: strings.NewReplacer("V1", "", "V2", ""), }, Logger: logger.New( &log.Logger, diff --git a/tests/division_test.go b/tests/division_test.go index 3e42c4c..4522be9 100644 --- a/tests/division_test.go +++ b/tests/division_test.go @@ -68,7 +68,7 @@ func TestDeleteDivision(t *testing.T) { id := 3 toID := 2 - hole := Hole{DivisionID: id} + hole := Hole{BaseHole: BaseHole{DivisionID: id}} DB.Create(&hole) testAPI(t, "delete", "/api/divisions/"+strconv.Itoa(id), 204, Map{"to": toID}) testAPI(t, "delete", "/api/divisions/"+strconv.Itoa(id), 204, Map{}) // repeat delete diff --git a/tests/init.go b/tests/init.go index 99d86e5..c4603d6 100644 --- a/tests/init.go +++ b/tests/init.go @@ -33,7 +33,7 @@ func initTestDivision() { holes := make(Holes, 10) for i := range holes { holes[i] = &Hole{ - DivisionID: 1, + BaseHole: BaseHole{DivisionID: 1}, } } holes[9].DivisionID = 4 // for TestDeleteDivisionDefaultValue @@ -51,7 +51,7 @@ func initTestHoles() { holes := make(Holes, 10) for i := range holes { holes[i] = &Hole{ - DivisionID: 6, + BaseHole: BaseHole{DivisionID: 6}, } } tag := Tag{Name: "114", Temperature: 15} @@ -73,7 +73,7 @@ func initTestFloors() { holes := make(Holes, 10) for i := range holes { holes[i] = &Hole{ - DivisionID: 7, + BaseHole: BaseHole{DivisionID: 7}, } } for i := 1; i <= 50; i++ { @@ -111,7 +111,9 @@ func initTestTags() { } // int[tag_id][hole_id] for i := range holes { - holes[i] = &Hole{DivisionID: 8} + holes[i] = &Hole{ + BaseHole: BaseHole{DivisionID: 8}, + } } for i := range tags { @@ -153,7 +155,7 @@ const ( ) func initTestReports() { - hole := Hole{ID: 1000} + hole := Hole{BaseHole: BaseHole{ID: 1000}} floors := make(Floors, 20) for i := range floors { floors[i] = &Floor{