- P2-3 ExportModelsJson N+1 改批量 WhereIn 查询定价 - P2-4 dashboard 聚合 Count 不再静默吞错,改 Warning 日志 - P2-5 promo_code/notification 的 map[string]any 返回值改 struct - P2-6 CleanExpiredPreDeducts 改先 claim 再 release,避免多实例重复释放 - P2-7 trackPreDeduct 已由 P1-C 同步化+回滚覆盖,文档注明 - P2-8 支付金额容差比较改 decimal,消除 0.01 边界二进制误差 - P2-9 GetEnabledChannels 加 60s 进程内缓存,配置变更主动失效 - P2-10 OAuth callback 路径限定 provider 白名单 (github/google) - P2-12 evaluateCondition eq 改浮点容差比较 - P2-13 api_metrics 加平台域调用方校验,防租户域误用泄露 - P2-14 删除 listProjectApiKeys 死代码
428 lines
11 KiB
Go
428 lines
11 KiB
Go
package admin
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/gogf/gf/v2/frame/g"
|
|
"github.com/gogf/gf/v2/os/gtime"
|
|
|
|
v1 "github.com/qianfree/team-api/api/admin/v1"
|
|
"github.com/qianfree/team-api/internal/dao"
|
|
"github.com/qianfree/team-api/internal/logic/common"
|
|
do "github.com/qianfree/team-api/internal/model/do"
|
|
)
|
|
|
|
// ListTemplates 获取通知模板列表(分页)
|
|
func (s *sAdmin) ListTemplates(ctx context.Context, req *v1.TemplateListReq) (*v1.TemplateListRes, error) {
|
|
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
|
|
|
var total int
|
|
items := make([]*v1.TemplateItem, 0)
|
|
err := dao.NtfTemplates.Ctx(ctx).
|
|
OrderAsc("code").
|
|
Page(page, pageSize).
|
|
ScanAndCount(&items, &total, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &v1.TemplateListRes{
|
|
List: items,
|
|
Total: total,
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
}, nil
|
|
}
|
|
|
|
// GetTemplate 获取单个通知模板
|
|
func (s *sAdmin) GetTemplate(ctx context.Context, req *v1.TemplateGetReq) (*v1.TemplateGetRes, error) {
|
|
var tpl *v1.TemplateGetRes
|
|
err := dao.NtfTemplates.Ctx(ctx).
|
|
Where("code", req.Code).
|
|
Scan(&tpl)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tpl == nil {
|
|
return nil, common.NewNotFoundError("通知模板")
|
|
}
|
|
return tpl, nil
|
|
}
|
|
|
|
// UpdateTemplate 更新通知模板
|
|
func (s *sAdmin) UpdateTemplate(ctx context.Context, req *v1.TemplateUpdateReq) (*v1.TemplateUpdateRes, error) {
|
|
data := do.NtfTemplates{}
|
|
if req.Subject != "" {
|
|
data.Subject = req.Subject
|
|
}
|
|
if req.BodyTemplate != "" {
|
|
data.BodyTemplate = req.BodyTemplate
|
|
}
|
|
if req.Channel != "" {
|
|
data.Channel = req.Channel
|
|
}
|
|
|
|
_, err := dao.NtfTemplates.Ctx(ctx).
|
|
Where("code", req.Code).
|
|
Data(data).
|
|
Update()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &v1.TemplateUpdateRes{}, nil
|
|
}
|
|
|
|
// TestTemplate 用测试变量渲染模板,返回渲染结果(不发送)
|
|
func (s *sAdmin) TestTemplate(ctx context.Context, req *v1.TemplateTestReq) (*v1.TemplateTestRes, error) {
|
|
var tpl *struct {
|
|
Subject string `json:"subject"`
|
|
BodyTemplate string `json:"body_template"`
|
|
Channel string `json:"channel"`
|
|
}
|
|
err := dao.NtfTemplates.Ctx(ctx).
|
|
Where("code", req.Code).
|
|
Scan(&tpl)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tpl == nil {
|
|
return nil, common.NewNotFoundError("模板")
|
|
}
|
|
if tpl.BodyTemplate == "" {
|
|
return nil, common.NewNotFoundError("通知模板")
|
|
}
|
|
|
|
subjectRendered, subjectErr := renderGoTemplate(tpl.Subject, req.Variables)
|
|
bodyRendered, bodyErr := renderGoTemplate(tpl.BodyTemplate, req.Variables)
|
|
|
|
subjectResult := v1.TemplateRenderResult{
|
|
Original: tpl.Subject,
|
|
}
|
|
if subjectErr != nil {
|
|
subjectResult.Error = subjectErr.Error()
|
|
} else {
|
|
subjectResult.Rendered = subjectRendered
|
|
}
|
|
|
|
bodyResult := v1.TemplateRenderResult{
|
|
Original: tpl.BodyTemplate,
|
|
}
|
|
if bodyErr != nil {
|
|
bodyResult.Error = bodyErr.Error()
|
|
} else {
|
|
bodyResult.Rendered = bodyRendered
|
|
}
|
|
|
|
return &v1.TemplateTestRes{
|
|
Subject: subjectResult,
|
|
Body: bodyResult,
|
|
Channel: tpl.Channel,
|
|
}, nil
|
|
}
|
|
|
|
// SendMessage 创建手动站内消息并推送 WebSocket 通知
|
|
func (s *sAdmin) SendMessage(ctx context.Context, req *v1.MessageSendReq) (*v1.MessageSendRes, error) {
|
|
engine := common.NewNotificationEngine()
|
|
if req.UserID > 0 {
|
|
return nil, engine.SendMessage(ctx, req.TenantID, req.UserID, "system", req.Title, req.Content)
|
|
}
|
|
// 未指定用户时发送广播
|
|
return nil, engine.SendBroadcastMessage(ctx, req.TenantID, "system", req.Title, req.Content, "")
|
|
}
|
|
|
|
// SendBroadcast 创建广播消息并推送 WebSocket 通知
|
|
func (s *sAdmin) SendBroadcast(ctx context.Context, req *v1.MessageBroadcastReq) (*v1.MessageBroadcastRes, error) {
|
|
if req.Title == "" || req.Content == "" {
|
|
return nil, common.NewBadRequestError("标题和内容不能为空")
|
|
}
|
|
|
|
engine := common.NewNotificationEngine()
|
|
|
|
if req.TenantID != nil && *req.TenantID > 0 {
|
|
return nil, engine.SendBroadcastMessage(ctx, *req.TenantID, "system", req.Title, req.Content, req.TargetRoles)
|
|
}
|
|
|
|
// 广播到所有活跃租户
|
|
var tenants []struct {
|
|
Id int64 `json:"id"`
|
|
}
|
|
err := dao.TntTenants.Ctx(ctx).Where("status", "active").Fields("id").Scan(&tenants)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, t := range tenants {
|
|
if e := engine.SendBroadcastMessage(ctx, t.Id, "system", req.Title, req.Content, req.TargetRoles); e != nil {
|
|
g.Log().Warningf(ctx, "SendBroadcast to tenant %d failed: %v", t.Id, e)
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// ListMessages 获取所有消息列表(管理后台,支持过滤)
|
|
func (s *sAdmin) ListMessages(ctx context.Context, req *v1.MessageListReq) (*v1.MessageListRes, error) {
|
|
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
|
|
|
query := dao.NtfMessages.Ctx(ctx).
|
|
LeftJoin("tnt_tenants t", "t.id = ntf_messages.tenant_id").
|
|
LeftJoin("tnt_users u", "u.id = ntf_messages.user_id").
|
|
Fields("ntf_messages.*, t.name as tenant_name, u.display_name as user_name")
|
|
if req.TenantID > 0 {
|
|
query = query.Where("ntf_messages.tenant_id", req.TenantID)
|
|
}
|
|
if req.Type != "" {
|
|
query = query.Where("ntf_messages.type", req.Type)
|
|
}
|
|
|
|
var total int
|
|
items := make([]*v1.MessageItem, 0)
|
|
err := query.OrderDesc("ntf_messages.created_at").
|
|
Page(page, pageSize).
|
|
ScanAndCount(&items, &total, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &v1.MessageListRes{
|
|
List: items,
|
|
Total: total,
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
}, nil
|
|
}
|
|
|
|
// MessageReadStats 是广播消息已读统计的类型化结果,替代原先 map[string]any +
|
|
// 调用方类型断言的写法(P2-5:消除类型不安全)。
|
|
type MessageReadStats struct {
|
|
MessageID int64 `json:"message_id"`
|
|
TotalMembers int `json:"total_members"`
|
|
ReadCount int `json:"read_count"`
|
|
UnreadCount int `json:"unread_count"`
|
|
ReadRate string `json:"read_rate"` // 形如 "85.7%"
|
|
}
|
|
|
|
// GetMessageReadStats 获取广播消息的已读统计
|
|
func GetMessageReadStats(ctx context.Context, messageID int64) (*MessageReadStats, error) {
|
|
var msg *struct {
|
|
IsBroadcast int `json:"is_broadcast"`
|
|
TenantID int64 `json:"tenant_id"`
|
|
}
|
|
err := dao.NtfMessages.Ctx(ctx).
|
|
Where("id", messageID).
|
|
Fields("is_broadcast, tenant_id").
|
|
Scan(&msg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if msg == nil {
|
|
return nil, common.NewNotFoundError("消息")
|
|
}
|
|
if msg.IsBroadcast != 1 {
|
|
return nil, common.NewBadRequestError("该消息不是广播消息")
|
|
}
|
|
|
|
totalMembers, err := dao.TntUsers.Ctx(ctx).
|
|
Where("tenant_id", msg.TenantID).
|
|
Where("status", "active").
|
|
Count()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
readCount, err := dao.NtfReadStatus.Ctx(ctx).
|
|
Where("message_id", messageID).
|
|
Count()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
unreadCount := totalMembers - readCount
|
|
if unreadCount < 0 {
|
|
unreadCount = 0
|
|
}
|
|
|
|
readRate := float64(0)
|
|
if totalMembers > 0 {
|
|
readRate = float64(readCount) / float64(totalMembers) * 100
|
|
}
|
|
|
|
return &MessageReadStats{
|
|
MessageID: messageID,
|
|
TotalMembers: totalMembers,
|
|
ReadCount: readCount,
|
|
UnreadCount: unreadCount,
|
|
ReadRate: fmt.Sprintf("%.1f%%", readRate),
|
|
}, nil
|
|
}
|
|
|
|
// CreateAnnouncement 创建公告
|
|
func (s *sAdmin) CreateAnnouncement(ctx context.Context, req *v1.AnnouncementCreateReq) (*v1.AnnouncementCreateRes, error) {
|
|
annType := req.Type
|
|
if annType == "" {
|
|
annType = "info"
|
|
}
|
|
status := req.Status
|
|
if status == "" {
|
|
status = "draft"
|
|
}
|
|
displayPosition := req.DisplayPosition
|
|
if displayPosition == "" {
|
|
displayPosition = "console"
|
|
}
|
|
|
|
// Get admin user ID from context
|
|
adminUserID := common.GetCtxUserID(ctx)
|
|
|
|
data := do.NtfAnnouncements{
|
|
Title: req.Title,
|
|
Type: annType,
|
|
Content: req.Content,
|
|
Status: status,
|
|
IsPinned: req.IsPinned,
|
|
DisplayPosition: displayPosition,
|
|
CreatedBy: adminUserID,
|
|
}
|
|
if req.EffectiveAt != "" {
|
|
if t, err := time.Parse(time.RFC3339, req.EffectiveAt); err == nil {
|
|
data.EffectiveAt = gtime.NewFromTime(t)
|
|
}
|
|
}
|
|
if req.ExpiresAt != "" {
|
|
if t, err := time.Parse(time.RFC3339, req.ExpiresAt); err == nil {
|
|
data.ExpiresAt = gtime.NewFromTime(t)
|
|
}
|
|
}
|
|
|
|
result, err := dao.NtfAnnouncements.Ctx(ctx).Insert(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, _ := result.LastInsertId()
|
|
return &v1.AnnouncementCreateRes{ID: id}, nil
|
|
}
|
|
|
|
// UpdateAnnouncement 更新公告
|
|
func (s *sAdmin) UpdateAnnouncement(ctx context.Context, req *v1.AnnouncementUpdateReq) (*v1.AnnouncementUpdateRes, error) {
|
|
data := do.NtfAnnouncements{}
|
|
|
|
if req.Title != "" {
|
|
data.Title = req.Title
|
|
}
|
|
if req.Type != "" {
|
|
data.Type = req.Type
|
|
}
|
|
if req.Content != "" {
|
|
data.Content = req.Content
|
|
}
|
|
if req.Status != "" {
|
|
data.Status = req.Status
|
|
}
|
|
if req.IsPinned != nil {
|
|
data.IsPinned = *req.IsPinned
|
|
}
|
|
if req.DisplayPosition != "" {
|
|
data.DisplayPosition = req.DisplayPosition
|
|
}
|
|
if req.EffectiveAt != "" {
|
|
if t, err := gtime.StrToTime(req.EffectiveAt); err == nil {
|
|
data.EffectiveAt = t
|
|
}
|
|
}
|
|
if req.ExpiresAt != "" {
|
|
if t, err := gtime.StrToTime(req.ExpiresAt); err == nil {
|
|
data.ExpiresAt = t
|
|
}
|
|
}
|
|
|
|
_, err := dao.NtfAnnouncements.Ctx(ctx).
|
|
Where("id", req.Id).
|
|
Data(data).
|
|
Update()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &v1.AnnouncementUpdateRes{}, nil
|
|
}
|
|
|
|
// ListAnnouncements 获取公告列表(分页)
|
|
func (s *sAdmin) ListAnnouncements(ctx context.Context, req *v1.AnnouncementListReq) (*v1.AnnouncementListRes, error) {
|
|
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
|
|
|
query := dao.NtfAnnouncements.Ctx(ctx)
|
|
if req.Status != "" {
|
|
query = query.Where("status", req.Status)
|
|
}
|
|
|
|
var total int
|
|
items := make([]*v1.AnnouncementItem, 0)
|
|
err := query.OrderDesc("is_pinned").
|
|
OrderDesc("created_at").
|
|
Page(page, pageSize).
|
|
ScanAndCount(&items, &total, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &v1.AnnouncementListRes{
|
|
List: items,
|
|
Total: total,
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
}, nil
|
|
}
|
|
|
|
// PublishAnnouncement 发布公告
|
|
func (s *sAdmin) PublishAnnouncement(ctx context.Context, req *v1.AnnouncementPublishReq) (*v1.AnnouncementPublishRes, error) {
|
|
count, err := dao.NtfAnnouncements.Ctx(ctx).Where("id", req.Id).Count()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if count == 0 {
|
|
return nil, common.NewNotFoundError("公告")
|
|
}
|
|
_, err = dao.NtfAnnouncements.Ctx(ctx).
|
|
Where("id", req.Id).
|
|
Data(do.NtfAnnouncements{
|
|
Status: "published",
|
|
}).Update()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &v1.AnnouncementPublishRes{}, nil
|
|
}
|
|
|
|
// ArchiveAnnouncement 归档公告
|
|
func (s *sAdmin) ArchiveAnnouncement(ctx context.Context, req *v1.AnnouncementArchiveReq) (*v1.AnnouncementArchiveRes, error) {
|
|
_, err := dao.NtfAnnouncements.Ctx(ctx).
|
|
Where("id", req.Id).
|
|
Data(do.NtfAnnouncements{
|
|
Status: "archived",
|
|
}).Update()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &v1.AnnouncementArchiveRes{}, nil
|
|
}
|
|
|
|
// renderGoTemplate renders a Go template string with variables.
|
|
func renderGoTemplate(tplStr string, vars map[string]any) (string, error) {
|
|
if tplStr == "" {
|
|
return "", nil
|
|
}
|
|
t, err := template.New("").Parse(tplStr)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := t.Execute(&buf, vars); err != nil {
|
|
return "", err
|
|
}
|
|
return buf.String(), nil
|
|
}
|