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