Files
qitongxue-api/internal/logic/message.go
2026-09-30 17:01:18 +08:00

224 lines
7.4 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"
"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
}