Files
qitongxue-api/internal/logic/note.go

477 lines
14 KiB
Go
Raw Normal View History

2026-09-29 10:57:01 +08:00
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)。
2026-09-29 15:48:13 +08:00
// 标题留空时自动命名为「未命名笔记 YYYY-MM-DD hh:mm:ss」(用户端不强制起名)。
2026-09-29 10:57:01 +08:00
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
}
2026-09-29 15:48:13 +08:00
title := strings.TrimSpace(req.Title)
if title == "" {
title = "未命名笔记 " + now.Format("Y-m-d H:i:s")
}
2026-09-29 10:57:01 +08:00
// 编辑既有笔记:不占用额度
if req.Id > 0 {
data := g.Map{
2026-09-29 15:48:13 +08:00
"title": title,
2026-09-29 10:57:01 +08:00
"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,
2026-09-29 15:48:13 +08:00
"title": title,
2026-09-29 10:57:01 +08:00
"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)
}