package logic import ( "context" "fmt" "strings" "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" v1 "tool-api/api/user/v1" "tool-api/internal/consts" "tool-api/internal/model/entity" ) // ============================================================================ // 笔记域 // // 额度规则(PRD-02 §4.1.1,与既有三池额度体系「不混用」): // - 免费用户活跃笔记数(deleted_at IS NULL)≤ 50; // - 会员不限量; // - 新建计入、编辑不计入、删除(软删)立即释放额度; // - 会员判定一律走 EffectiveLevelKey,保证「会员过期即回落免费额度」。 // // 与「50 是总量还是每月」的结论:总量上限。笔记是内容资产而非使用次数, // 故不设周期、不重置(与 quota.go 的按周期扣减刻意区分)。 // ============================================================================ // ===== 自定义错误码(沿用 gcodeQuotaExhausted 范式)===== func gcodeNoteLimit() gcode.Code { return gcode.New(consts.CodeNoteLimit, "", nil) } func gcodeNoteNotFound() gcode.Code { return gcode.New(consts.CodeNoteNotFound, "", nil) } // ===== 额度判定(纯函数,便于单测覆盖边界)===== // evaluateNoteQuota 根据「活跃笔记数 + 是否会员」计算额度展示与是否允许新建。 // // 返回: // - limit 免费上限(会员为 0,表示不限) // - unlimited 是否不限量 // - canCreate 当前是否允许新建/恢复 // // 设计要点:会员过期且活跃数 > 50 时,canCreate=false(仅禁止新建), // 已存在的笔记保留不动(PRD-02 R6-4 / Q8)。 func evaluateNoteQuota(activeCount int, isMember bool) (limit int, unlimited bool, canCreate bool) { if isMember { return 0, true, true } return consts.NoteFreeLimit, false, activeCount < consts.NoteFreeLimit } // activeNoteCount 统计用户活跃笔记数(未软删除) func activeNoteCount(ctx context.Context, userId int64) (int, error) { return g.Model(consts.TableNotes). Where("user_id", userId). WhereNull("deleted_at"). Count() } // noteUser 取当前登录用户并计算会员态。会员判定唯一入口 = EffectiveLevelKey。 func noteUser(ctx context.Context, userId int64) (*entity.Users, bool, error) { user, err := getUserById(ctx, userId) if err != nil { return nil, false, err } isMember := EffectiveLevelKey(user) != consts.DefaultLevelKey return user, isMember, nil } // ===== 列表 ===== // NoteList 笔记列表(分页 / 关键词搜索 / 标签筛选),附带额度计数。 func NoteList(ctx context.Context, req *v1.NoteListReq) (*v1.NoteListRes, error) { userId := CtxUserId(ctx) _, isMember, err := noteUser(ctx, userId) if err != nil { return nil, err } activeCount, err := activeNoteCount(ctx, userId) if err != nil { return nil, err } limit, _, _ := evaluateNoteQuota(activeCount, isMember) model := g.Model(consts.TableNotes). Where("user_id", userId). WhereNull("deleted_at") if kw := strings.TrimSpace(req.Keyword); kw != "" { like := "%" + kw + "%" model = model.Where("(title LIKE ? OR content LIKE ?)", like, like) } if tag := strings.TrimSpace(req.Tag); tag != "" { // tags 为逗号分隔串,FIND_IN_SET 精确匹配单个标签 model = model.Where("FIND_IN_SET(?, tags)", tag) } total, err := model.Count() if err != nil { return nil, err } page, size := normalizeNotePage(req.Page, req.PageSize) records, err := model.OrderDesc("is_pinned").OrderDesc("updated_at").Page(page, size).All() if err != nil { return nil, err } list := make([]v1.NoteOut, 0, len(records)) for _, r := range records { note := &entity.Notes{} if err = r.Struct(note); err != nil { continue } list = append(list, noteOutFromEntity(note)) } return &v1.NoteListRes{ List: list, Total: total, ActiveCount: activeCount, Limit: limit, IsMember: isMember, }, nil } // ===== 详情 ===== // NoteDetail 单条笔记详情(仅本人、且未删除)。 func NoteDetail(ctx context.Context, req *v1.NoteDetailReq) (*v1.NoteDetailRes, error) { userId := CtxUserId(ctx) note, err := findActiveNote(ctx, userId, req.Id) if err != nil { return nil, err } res := &v1.NoteDetailRes{ Id: note.Id, Title: note.Title, Content: note.Content, Tags: splitNoteTags(note.Tags), IsPinned: note.IsPinned == 1, RemindStatus: note.RemindStatus, } if note.RemindAt != nil && !note.RemindAt.IsZero() { res.RemindAt = note.RemindAt.Format("Y-m-d H:i:s") } return res, nil } // ===== 新建 / 编辑 ===== // SaveNote 新建(Id=0)或编辑(Id>0)笔记。 // 新建计入额度校验;编辑不计入额度(PRD-02 R6-5)。 // 标题留空时自动命名为「未命名笔记 YYYY-MM-DD hh:mm:ss」(用户端不强制起名)。 func SaveNote(ctx context.Context, req *v1.NoteSaveReq) (*v1.NoteSaveRes, error) { userId := CtxUserId(ctx) _, isMember, err := noteUser(ctx, userId) if err != nil { return nil, err } remindAt, err := parseNoteRemind(req.RemindAt) if err != nil { return nil, err } now := gtime.Now() remindStatus := noteRemindStatusOf(remindAt, now) tags := joinNoteTags(req.Tags) pinned := 0 if req.IsPinned { pinned = 1 } title := strings.TrimSpace(req.Title) if title == "" { title = "未命名笔记 " + now.Format("Y-m-d H:i:s") } // 编辑既有笔记:不占用额度 if req.Id > 0 { data := g.Map{ "title": title, "content": req.Content, "tags": tags, "is_pinned": pinned, "updated_at": now, "remind_at": remindAt, "remind_status": remindStatus, } if remindAt == nil { // 取消提醒:清空发送时间 data["remind_sent_at"] = nil } result, err := g.Model(consts.TableNotes). Where("id", req.Id). Where("user_id", userId). WhereNull("deleted_at"). Data(data). Update() if err != nil { return nil, err } if affected, _ := result.RowsAffected(); affected == 0 { return nil, gerror.NewCode(gcodeNoteNotFound(), "笔记不存在") } activeCount, err := activeNoteCount(ctx, userId) if err != nil { return nil, err } return &v1.NoteSaveRes{Id: req.Id, ActiveCount: activeCount}, nil } // 新建:先校验额度 activeCount, err := activeNoteCount(ctx, userId) if err != nil { return nil, err } if _, _, canCreate := evaluateNoteQuota(activeCount, isMember); !canCreate { return nil, gerror.NewCode(gcodeNoteLimit(), noteLimitMessage()) } id, err := g.Model(consts.TableNotes).Data(g.Map{ "user_id": userId, "title": title, "content": req.Content, "tags": tags, "is_pinned": pinned, "remind_at": remindAt, "remind_status": remindStatus, "remind_sent_at": nil, "remind_attempts": 0, "subscribe_count": 0, "created_at": now, "updated_at": now, }).InsertAndGetId() if err != nil { return nil, err } return &v1.NoteSaveRes{Id: id, ActiveCount: activeCount + 1}, nil } // ===== 删除(软删,释放额度)===== // DeleteNote 软删除笔记,deleted_at 置位后立即不再计入活跃数与额度。 func DeleteNote(ctx context.Context, req *v1.NoteDeleteReq) (*v1.NoteDeleteRes, error) { userId := CtxUserId(ctx) now := gtime.Now() result, err := g.Model(consts.TableNotes). Where("id", req.Id). Where("user_id", userId). WhereNull("deleted_at"). Data(g.Map{"deleted_at": now, "updated_at": now}). Update() if err != nil { return nil, err } if affected, _ := result.RowsAffected(); affected == 0 { return nil, gerror.NewCode(gcodeNoteNotFound(), "笔记不存在") } return &v1.NoteDeleteRes{}, nil } // ===== 恢复(P2,回收站)===== // RestoreNote 从回收站恢复;恢复计入额度,超过上限则提示先清理(PRD-02 R6-14)。 func RestoreNote(ctx context.Context, req *v1.NoteRestoreReq) (*v1.NoteRestoreRes, error) { userId := CtxUserId(ctx) _, isMember, err := noteUser(ctx, userId) if err != nil { return nil, err } records, err := g.Model(consts.TableNotes). Where("id", req.Id). Where("user_id", userId). WhereNotNull("deleted_at"). Limit(1). All() if err != nil { return nil, err } if len(records) == 0 { return nil, gerror.NewCode(gcodeNoteNotFound(), "笔记不存在") } activeCount, err := activeNoteCount(ctx, userId) if err != nil { return nil, err } if _, _, canCreate := evaluateNoteQuota(activeCount, isMember); !canCreate { return nil, gerror.NewCode(gcodeNoteLimit(), "免费版最多 "+itoa(consts.NoteFreeLimit)+" 条,请先清理后再恢复") } now := gtime.Now() if _, err = g.Model(consts.TableNotes). Where("id", req.Id). Where("user_id", userId). Data(g.Map{"deleted_at": nil, "updated_at": now}). Update(); err != nil { return nil, err } return &v1.NoteSaveRes{Id: req.Id, ActiveCount: activeCount + 1}, nil } // ===== 取消提醒 ===== // CancelRemind 清除某条笔记的提醒设置。 func CancelRemind(ctx context.Context, req *v1.NoteRemindCancelReq) (*v1.NoteRemindCancelRes, error) { userId := CtxUserId(ctx) result, err := g.Model(consts.TableNotes). Where("id", req.Id). Where("user_id", userId). WhereNull("deleted_at"). Data(g.Map{ "remind_at": nil, "remind_status": consts.NoteRemindNone, "remind_sent_at": nil, "updated_at": gtime.Now(), }). Update() if err != nil { return nil, err } if affected, _ := result.RowsAffected(); affected == 0 { return nil, gerror.NewCode(gcodeNoteNotFound(), "笔记不存在") } return &v1.NoteRemindCancelRes{}, nil } // ===== 订阅授权上报 ===== // ReportSubscribe 累加笔记的订阅授权可发送次数(前端 requestSubscribeMessage 成功后调用)。 func ReportSubscribe(ctx context.Context, req *v1.NoteSubscribeReportReq) (*v1.NoteSubscribeReportRes, error) { userId := CtxUserId(ctx) result, err := g.Model(consts.TableNotes). Where("id", req.NoteId). Where("user_id", userId). WhereNull("deleted_at"). Increment("subscribe_count", req.Count) if err != nil { return nil, err } if affected, _ := result.RowsAffected(); affected == 0 { return nil, gerror.NewCode(gcodeNoteNotFound(), "笔记不存在") } return &v1.NoteSubscribeReportRes{}, nil } // ===== 内部辅助 ===== // findActiveNote 读取本人未删除笔记;不存在或无权时返回 4004。 func findActiveNote(ctx context.Context, userId, id int64) (*entity.Notes, error) { record, err := g.Model(consts.TableNotes). Where("id", id). Where("user_id", userId). WhereNull("deleted_at"). One() if err != nil { return nil, err } if record.IsEmpty() { return nil, gerror.NewCode(gcodeNoteNotFound(), "笔记不存在") } note := &entity.Notes{} if err = record.Struct(note); err != nil { return nil, err } return note, nil } func noteOutFromEntity(note *entity.Notes) v1.NoteOut { out := v1.NoteOut{ Id: note.Id, Title: note.Title, Summary: noteSummary(note.Content, 80), Tags: splitNoteTags(note.Tags), IsPinned: note.IsPinned == 1, RemindStatus: note.RemindStatus, } if note.RemindAt != nil && !note.RemindAt.IsZero() { out.RemindAt = note.RemindAt.Format("Y-m-d H:i:s") } if note.UpdatedAt != nil && !note.UpdatedAt.IsZero() { out.UpdatedAt = note.UpdatedAt.Format("Y-m-d H:i:s") } return out } // parseNoteRemind 解析提醒时间字符串;空串表示不设/取消提醒。 func parseNoteRemind(s string) (*gtime.Time, error) { s = strings.TrimSpace(s) if s == "" { return nil, nil } t, err := gtime.StrToTime(s) if err != nil { return nil, gerror.NewCode(gcode.CodeValidationFailed, "提醒时间格式不正确") } return t, nil } // noteRemindStatusOf 依据提醒时间与当前时间决定初始提醒状态: // 未来时间 → 待提醒(1);过去时间 → 已过期(4);无提醒 → 未设置(0)。 func noteRemindStatusOf(remindAt, now *gtime.Time) int { if remindAt == nil || remindAt.IsZero() { return consts.NoteRemindNone } if remindAt.After(now) { return consts.NoteRemindPending } return consts.NoteRemindExpired } // joinNoteTags 规整标签:去空白、去空项、去重,逗号拼接,超长截断(varchar(255))。 func joinNoteTags(tags []string) string { seen := make(map[string]bool, len(tags)) out := make([]string, 0, len(tags)) for _, t := range tags { t = strings.TrimSpace(t) if t == "" || seen[t] { continue } seen[t] = true out = append(out, t) } csv := strings.Join(out, ",") if rs := []rune(csv); len(rs) > 255 { csv = string(rs[:255]) } return csv } // splitNoteTags 拆分逗号分隔标签串 func splitNoteTags(csv string) []string { out := make([]string, 0) for _, t := range strings.Split(csv, ",") { if t = strings.TrimSpace(t); t != "" { out = append(out, t) } } return out } // noteSummary 生成正文摘要(列表不返回全文),单行化并截断。 func noteSummary(content string, max int) string { s := strings.ReplaceAll(content, "\r", " ") s = strings.ReplaceAll(s, "\n", " ") s = strings.TrimSpace(s) rs := []rune(s) if len(rs) > max { return string(rs[:max]) + "…" } return s } // normalizeNotePage 分页兜底:page≥1,1≤pageSize≤100。 func normalizeNotePage(page, size int) (int, int) { if page < 1 { page = 1 } if size <= 0 { size = 20 } if size > 100 { size = 100 } return page, size } func noteLimitMessage() string { return "免费版最多 " + itoa(consts.NoteFreeLimit) + " 条,开通会员可不限量" } // itoa 小整数转字符串(避免为一个格式化再引 strconv 到多处) func itoa(v int) string { return fmt.Sprintf("%d", v) }