Files
qitongxue-api/internal/logic/note.go
2026-09-29 10:57:01 +08:00

472 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)。
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
}
// 编辑既有笔记:不占用额度
if req.Id > 0 {
data := g.Map{
"title": req.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": req.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)
}