472 lines
13 KiB
Go
472 lines
13 KiB
Go
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)
|
||
}
|