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

224 lines
7.4 KiB
Go
Raw Normal View History

2026-09-30 17:01:18 +08:00
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
}