Files
martin a7177cc6f5 fix: 修复 P2 可维护性与性能问题
- 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 死代码
2026-07-26 12:31:41 +08:00

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
}