package logic import ( "context" "strings" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" adminv1 "tool-api/api/admin/v1" userv1 "tool-api/api/user/v1" "tool-api/internal/consts" ) // ============================================================================ // 站内信(迭代-2026-09-30) // // 业务事件统一经 PushMessage 落 user_messages(会员开通/次数包到账、报价回执、 // 报价处理结果、反馈官方回复等)。推送是**尽力而为**:失败只告警,绝不让业务 // 主流程(发货/报价/回复)因站内信失败而回滚 —— 唯一例外是发货事务内的写入 // (用 tx 直插,与发货同事务保证原子,见 member.go deliverOrder)。 // // TZ-01:datetime 写库一律 gdb.Raw("NOW()")。 // ============================================================================ // PushMessage 写一条站内信(尽力而为:失败告警不影响调用方)。 // content 截断到 500 字、link 截断到 255 字(与表结构上限对齐)。 func PushMessage(ctx context.Context, userId int64, msgType, title, content, link string) { if userId <= 0 { return } if len(content) > 500 { content = content[:500] } if len(link) > 255 { link = link[:255] } _, err := g.Model(consts.TableUserMessages).Data(g.Map{ "user_id": userId, "type": msgType, "title": title, "content": content, "link": link, "is_read": 0, "created_at": gdb.Raw("NOW()"), }).Insert() if err != nil { g.Log().Warningf(ctx, "[message] 站内信写入失败 userId=%d type=%s: %v", userId, msgType, err) } } // MessageList 我的消息(最近 N 条 + 未读数)。不自动置已读:红点语义是 // 「先看到未读数 → 进消息中心 → 手动/进入后清零」,已读走 MessageMarkRead。 func MessageList(ctx context.Context, limit int) (*userv1.MessageListRes, error) { if limit <= 0 || limit > 50 { limit = 20 } userId := CtxUserId(ctx) records, err := g.Model(consts.TableUserMessages). Where("user_id", userId). OrderDesc("id"). Limit(limit). All() if err != nil { return nil, err } unread, err := g.Model(consts.TableUserMessages). Where("user_id", userId). Where("is_read", 0). Count() if err != nil { return nil, err } list := make([]userv1.MessageItem, 0, len(records)) for _, r := range records { list = append(list, userv1.MessageItem{ Id: r["id"].Int64(), Type: r["type"].String(), Title: r["title"].String(), Content: r["content"].String(), Link: r["link"].String(), IsRead: r["is_read"].Int(), CreatedAt: timeStr(r["created_at"].GTime()), }) } return &userv1.MessageListRes{List: list, Unread: unread}, nil } // MessageUnreadCount 未读消息数(首页 / 我的页徽标数据源) func MessageUnreadCount(ctx context.Context) (*userv1.MessageUnreadRes, error) { count, err := g.Model(consts.TableUserMessages). Where("user_id", CtxUserId(ctx)). Where("is_read", 0). Count() if err != nil { return nil, err } return &userv1.MessageUnreadRes{Count: count}, nil } // MessageMarkRead 标记已读:id>0 单条(校验归属);id=0 全部。 func MessageMarkRead(ctx context.Context, id int64) (*userv1.MessageReadRes, error) { m := g.Model(consts.TableUserMessages). Where("user_id", CtxUserId(ctx)). Where("is_read", 0) if id > 0 { m = m.Where("id", id) } if _, err := m.Data(g.Map{"is_read": 1}).Update(); err != nil { return nil, err } return &userv1.MessageReadRes{}, nil } // ============================================================================ // 管理端:报价申请列表 / 处理 // ============================================================================ // AdminServiceQuoteList 报价申请列表(分页 + 状态筛选,带用户昵称) func AdminServiceQuoteList(ctx context.Context, req *adminv1.AdminServiceQuoteListReq) (*adminv1.AdminServiceQuoteListRes, error) { page, pageSize := normalizePage(req.Page, req.PageSize, 20) m := g.Model(consts.TableServiceQuotes) if req.Status != nil { m = m.Where("status", *req.Status) } total, err := m.Count() if err != nil { return nil, err } records, err := m.Page(page, pageSize).OrderDesc("id").All() if err != nil { return nil, err } // 用户昵称映射(用户可能已被软删,查不到时留空展示 id) userIds := make([]int64, 0, len(records)) for _, r := range records { userIds = append(userIds, r["user_id"].Int64()) } nicknames := map[int64]string{} if len(userIds) > 0 { rows, err := g.Model(consts.TableUsers).WhereIn("id", userIds).All() if err == nil { for _, u := range rows { nicknames[u["id"].Int64()] = u["nickname"].String() } } } list := make([]adminv1.AdminServiceQuoteItem, 0, len(records)) for _, r := range records { uid := r["user_id"].Int64() list = append(list, adminv1.AdminServiceQuoteItem{ Id: r["id"].Int64(), UserId: uid, UserNickname: nicknames[uid], WorkId: r["work_id"].Int64(), WorkTitle: r["work_title"].String(), Contact: r["contact"].String(), Requirement: r["requirement"].String(), Budget: r["budget"].String(), Status: r["status"].Int(), StatusText: consts.QuoteStatusNameOf(r["status"].Int()), QuoteCents: r["quote_cents"].Int64(), QuoteNote: r["quote_note"].String(), HandledAt: timeStr(r["handled_at"].GTime()), CreatedAt: timeStr(r["created_at"].GTime()), }) } return &adminv1.AdminServiceQuoteListRes{List: list, Total: total}, nil } // AdminServiceQuoteHandle 处理报价申请:落状态/报价/说明 + 站内信通知用户。 func AdminServiceQuoteHandle(ctx context.Context, req *adminv1.AdminServiceQuoteHandleReq) (*adminv1.AdminServiceQuoteHandleRes, error) { r, err := g.Model(consts.TableServiceQuotes).Where("id", req.Id).One() if err != nil { return nil, err } if r.IsEmpty() { return nil, gerror.New("报价申请不存在") } note := strings.TrimSpace(req.QuoteNote) if _, err = g.Model(consts.TableServiceQuotes).Where("id", req.Id).Data(g.Map{ "status": req.Status, "quote_cents": req.QuoteCents, "quote_note": note, "handled_by": CtxAdminId(ctx), "handled_at": gdb.Raw("NOW()"), "updated_at": gdb.Raw("NOW()"), }).Update(); err != nil { return nil, err } // 站内信:已报价/已成交/已关闭均通知(待处理回退不通知,避免噪声) if req.Status != consts.QuoteStatusPending { title := "你的报价申请有新进展" switch req.Status { case consts.QuoteStatusQuoted: title = "报价已出,快来看看" case consts.QuoteStatusDone: title = "报价申请已成交" case consts.QuoteStatusClosed: title = "报价申请已关闭" } content := "你的报价申请(" + r["work_title"].String() + ")当前状态:" + consts.QuoteStatusNameOf(req.Status) if req.QuoteCents > 0 { content += ",报价 ¥" + centsYuanText(req.QuoteCents) } if note != "" { content += "。" + note } PushMessage(ctx, r["user_id"].Int64(), consts.MsgTypeQuoteHandled, title, content, "/pkg-service/quotes") } WriteAudit(ctx, AuditEntry{ Action: "service.quote.handle", TargetType: "service_quote", TargetId: auditId(req.Id), Before: g.Map{"status": r["status"].Int()}, After: g.Map{"status": req.Status, "quote_cents": req.QuoteCents}, Result: consts.AuditResultSuccess, }) return &adminv1.AdminServiceQuoteHandleRes{}, nil }