This commit is contained in:
tamakyi
2026-10-19 01:58:00 +08:00
parent bd613a6f5a
commit 4592b6ef24
14 changed files with 608 additions and 244 deletions
+23
View File
@@ -30,6 +30,12 @@ type QuestionsStore interface {
GetByUserID(ctx context.Context, userID uint, opts GetQuestionsByUserIDOptions) ([]*Question, error)
GetByAskUserID(ctx context.Context, userID uint, opts GetQuestionsByAskUserIDOptions) ([]*Question, error)
AnswerByID(ctx context.Context, id uint, answer string) error
// AnswerByIDPrivate 写入回答的同时把题目置为私密(站方 L2「收窄公开」)。
//
// 做成单条 UPDATE 而不是「先 SetPrivate 再 AnswerByID」:
// - 两条语句之间存在「公开 + 已作答」的窗口,而 L2 的全部意义就是消灭这个窗口;
// - 若第二条失败,题目会变成「私密 + 无回答」,箱主等于凭空丢了一条内容。
AnswerByIDPrivate(ctx context.Context, id uint, answer string) error
DeleteByID(ctx context.Context, id uint) error
UpdateCensor(ctx context.Context, id uint, opts UpdateQuestionCensorOptions) error
Count(ctx context.Context, userID uint, opts GetQuestionsCountOptions) (int64, error)
@@ -371,6 +377,23 @@ func (db *questions) getBy(ctx context.Context, cursor *dbutil.Cursor, whereQuer
return questions, nil
}
// AnswerByIDPrivate 回答并强制私密。见接口注释:必须是一条 UPDATE。
func (db *questions) AnswerByIDPrivate(ctx context.Context, id uint, answer string) error {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrQuestionNotExist
}
return errors.Wrap(err, "get question by ID")
}
if err := db.WithContext(ctx).Model(&question).Where("id = ?", id).
Updates(map[string]interface{}{"answer": answer, "is_read": true, "is_private": true}).Error; err != nil {
return errors.Wrap(err, "update question answer as private")
}
return nil
}
func (db *questions) AnswerByID(ctx context.Context, id uint, answer string) error {
var question Question
if err := db.WithContext(ctx).First(&question, id).Error; err != nil {
+28
View File
@@ -31,6 +31,7 @@ func TestQuestions(t *testing.T) {
{"GetByUserID", testQuestionsGetByUserID},
{"GetByAskUserID", testQuestionsGetByAskUserID},
{"AnswerByID", testQuestionsAnswerByID},
{"AnswerByIDPrivate", testQuestionsAnswerByIDPrivate},
{"DeleteByID", testQuestionsDeleteByID},
{"UpdateCensor", testQuestionsUpdateCensor},
{"Count", testQuestionsCount},
@@ -322,6 +323,33 @@ func testQuestionsAnswerByID(t *testing.T, ctx context.Context, db *questions) {
})
}
// testQuestionsAnswerByIDPrivate 覆盖 L2「收窄公开」的写入语义:
// 回答与 is_private 必须在同一次写入里生效,不能出现「公开 + 已作答」的中间态。
func testQuestionsAnswerByIDPrivate(t *testing.T, ctx context.Context, db *questions) {
t.Run("normal", func(t *testing.T) {
_, err := db.Create(ctx, CreateQuestionOptions{
FromIP: "114.5.1.4",
UserID: 1,
Content: "Content - 1",
AskerUserID: 1,
})
require.Nil(t, err)
err = db.AnswerByIDPrivate(ctx, 1, "Answer - 1")
require.Nil(t, err)
got, err := db.GetByID(ctx, 1)
require.Nil(t, err)
require.Equal(t, "Answer - 1", got.Answer)
require.True(t, got.IsPrivate)
})
t.Run("not found", func(t *testing.T) {
err := db.AnswerByIDPrivate(ctx, 404, "Answer - 1")
require.Equal(t, ErrQuestionNotExist, err)
})
}
func testQuestionsDeleteByID(t *testing.T, ctx context.Context, db *questions) {
t.Run("normal", func(t *testing.T) {
_, err := db.Create(ctx, CreateQuestionOptions{
+18 -18
View File
@@ -202,29 +202,29 @@ func (s *restrictions) RevokeActive(ctx context.Context, userID uint) (int64, er
}
func (s *restrictions) ActiveLevel(ctx context.Context, userID uint) (RestrictionLevel, *UserRestriction, error) {
levels, err := s.ActiveLevels(ctx, []uint{userID})
if err != nil {
return "", nil, err
}
level, ok := levels[userID]
if !ok {
return "", nil, nil
}
var restriction UserRestriction
// 只查一次:这个方法在每次内容提交时都会被调用(发布拦截 + 审核严格度),
// 早先「先 ActiveLevels 再 First」是两次往返,1C1G 上没必要。
var rows []*UserRestriction
if err := s.db.WithContext(ctx).
Where("user_id = ?", userID).
Where("level = ?", string(level)).
Where("revoked_at IS NULL").
Where("expires_at > ?", time.Now()).
Order("created_at DESC").
First(&restriction).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return level, nil, nil
}
return "", nil, errors.Wrap(err, "find active restriction")
Find(&rows).Error; err != nil {
return "", nil, errors.Wrap(err, "list active restrictions")
}
return level, &restriction, nil
// 正常情况最多一条(Create 是替换语义),这里取最高级别以防历史脏数据叠加。
var best *UserRestriction
bestRank := 0
for _, row := range rows {
if rank, ok := restrictionLevelRank[RestrictionLevel(row.Level)]; ok && rank > bestRank {
best, bestRank = row, rank
}
}
if best == nil {
return "", nil, nil
}
return RestrictionLevel(best.Level), best, nil
}
func (s *restrictions) ActiveLevels(ctx context.Context, userIDs []uint) (map[uint]RestrictionLevel, error) {
+8 -1
View File
@@ -621,7 +621,12 @@ func (db *users) GetShowInHome(ctx context.Context, page, pageSize int, sort str
var users []*User
var count int64
if err := db.WithContext(ctx).Model(&User{}).Where("show_in_home = ?", true).Count(&count).Error; err != nil {
// 计数必须与下面的列表用同一套过滤,否则总数包含被处置箱主、实际行数更少,
// 首页会出现「最后一页空白」。
if err := db.WithContext(ctx).Model(&User{}).
Where("show_in_home = ?", true).
Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx))).
Count(&count).Error; err != nil {
return nil, 0, errors.Wrap(err, "count show in home users")
}
@@ -798,6 +803,8 @@ func (db *users) GetHotTags(ctx context.Context, limit int) ([]string, error) {
if err := db.WithContext(ctx).
Model(&User{}).
Where("show_in_home = ? AND tags IS NOT NULL AND tags != ''", true).
// 被处置箱主的标签不应再被推荐(热门标签是个显眼的曝光位)。
Where("id NOT IN (?)", activeRestrictionsSubQuery(db.WithContext(ctx))).
Pluck("tags", &tagsList).Error; err != nil {
return nil, errors.Wrap(err, "get user tags for hot tags")
}