From 9044bafdff8aff110a77068a945a65f403845bb4 Mon Sep 17 00:00:00 2001 From: yvan <8574526@qq.com> Date: Thu, 13 Aug 2026 22:04:27 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=85=AC=E5=91=8A=EF=BC=8C?= =?UTF-8?q?=E9=82=AE=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/config.yaml | 10 ++ docs/ADMIN_BACKEND_MIGRATION.md | 7 +- internal/biz/announcement.go | 100 +++++++++++++++ internal/biz/biz.go | 2 +- internal/biz/email.go | 55 +++++++++ internal/conf/conf.pb.go | 156 +++++++++++++++++++++--- internal/conf/conf.proto | 12 ++ internal/data/announcement.go | 127 +++++++++++++++++++ internal/data/data.go | 8 +- internal/data/email.go | 149 ++++++++++++++++++++++ internal/data/system.go | 5 +- internal/server/announcement_routes.go | 127 +++++++++++++++++++ internal/server/email_routes.go | 74 +++++++++++ internal/server/gin.go | 6 +- internal/server/system_config_routes.go | 28 ++++- internal/service/announcement.go | 86 +++++++++++++ internal/service/email.go | 20 +++ internal/service/service.go | 2 +- 18 files changed, 946 insertions(+), 28 deletions(-) create mode 100644 internal/biz/announcement.go create mode 100644 internal/biz/email.go create mode 100644 internal/data/announcement.go create mode 100644 internal/data/email.go create mode 100644 internal/server/announcement_routes.go create mode 100644 internal/server/email_routes.go create mode 100644 internal/service/announcement.go create mode 100644 internal/service/email.go diff --git a/configs/config.yaml b/configs/config.yaml index 954286b..354db76 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -29,3 +29,13 @@ admin: local: store_path: uploads/file path_prefix: uploads/file + email: + # Leave host/from/secret empty to disable SMTP error notifications. + to: "" + from: "" + host: "" + secret: "" + nickname: "" + port: 465 + is_ssl: true + is_login_auth: false diff --git a/docs/ADMIN_BACKEND_MIGRATION.md b/docs/ADMIN_BACKEND_MIGRATION.md index 8ff6aef..1eb3a3c 100644 --- a/docs/ADMIN_BACKEND_MIGRATION.md +++ b/docs/ADMIN_BACKEND_MIGRATION.md @@ -5,8 +5,8 @@ - 参考基线:工作区 `gva/server`,Git `02f37833`。 - 目标:保留 GVA Vue 管理端使用的 HTTP 路径、方法、请求字段、统一响应结构和核心业务语义,同时按 Kratos 的 `service -> biz -> data` 分层落地。 - 明确排除:代码生成、代码生成历史、插件打包/安装、AI/LLM、MCP、Skills,以及测试文件。 -- 当前基线没有公告或邮件的 router/model/service,因此没有虚构对应接口。 -- 源端排除 51 个接口名后剩余 167 个接口名,目标端全部覆盖;另增加 `GET /health`。由于不同分组存在 `upload`、`init` 等同名方法,目标实际注册路由总数为 168。 +- 公告与邮件位于 `gva/server/plugin`;两者均已按 Kratos 分层迁移,不保留 GVA 的插件式全局变量结构。 +- 推荐范围包含 177 个 GVA HTTP 路由,目标端全部覆盖;另增加 `GET /health`,目标实际注册路由总数为 178。 ## 模块契约矩阵 @@ -24,6 +24,8 @@ | 审计与日志 | 20 | 操作、登录、数据访问、错误日志以及安全的文件日志查看 | | 定时任务与 SSE | 9 | Cron CRUD/启停/触发、HTTP/本地执行器、执行日志、失败告警 SSE | | 媒体 | 15 | 文件库、附件分类、本地存储枚举、普通上传、秒传与分片续传 | +| 公告 | 8 | 公告 CRUD、批量删除、分页、发布者数据源和公开接口 | +| 邮件 | 2 | SMTP 测试、主动发送,以及失败请求邮件告警中间件 | ## 分层映射 @@ -43,6 +45,7 @@ - 超级管理员角色 `888` 保留全权限旁路;其他角色由 Casbin 策略控制。 - 代码中的 `*-gva` 字符串仅是原 Vue 图标库的图标标识,为页面显示兼容而保留;Go 文件、类型、变量和方法均使用实际职责命名。 - 当前首先实现 MySQL、Redis(不可用时退化为进程缓存)和本地文件存储;云 OSS 驱动没有混入领域层。 +- SMTP 参数位于 `admin.email`;未配置服务器、发件人或密钥时错误告警保持关闭,邮件接口会返回明确的配置错误。 ## 验证命令 diff --git a/internal/biz/announcement.go b/internal/biz/announcement.go new file mode 100644 index 0000000..6b6f540 --- /dev/null +++ b/internal/biz/announcement.go @@ -0,0 +1,100 @@ +package biz + +import ( + "context" + "encoding/json" + "errors" + "time" +) + +type Announcement struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + Title string + Content string + UserID *uint + Attachments json.RawMessage +} + +type AnnouncementFilter struct { + Page, PageSize int + StartCreatedAt, EndCreatedAt *time.Time +} + +type AnnouncementRepo interface { + Create(context.Context, *Announcement) error + Delete(context.Context, uint) error + DeleteByIDs(context.Context, []uint) error + Update(context.Context, *Announcement) error + Find(context.Context, uint) (*Announcement, error) + List(context.Context, AnnouncementFilter) ([]*Announcement, int64, error) + UserOptions(context.Context) ([]UserOption, error) +} + +type UserOption struct { + Label string + Value uint +} + +type AnnouncementUsecase struct{ repo AnnouncementRepo } + +func NewAnnouncementUsecase(repo AnnouncementRepo) *AnnouncementUsecase { + return &AnnouncementUsecase{repo: repo} +} + +func validateAnnouncement(item *Announcement) error { + if item == nil || item.Title == "" { + return errors.New("公告标题不能为空") + } + if len(item.Attachments) == 0 || !json.Valid(item.Attachments) { + item.Attachments = json.RawMessage("[]") + } + return nil +} + +func (uc *AnnouncementUsecase) Create(ctx context.Context, item *Announcement) error { + if err := validateAnnouncement(item); err != nil { + return err + } + return uc.repo.Create(ctx, item) +} + +func (uc *AnnouncementUsecase) Update(ctx context.Context, item *Announcement) error { + if item == nil || item.ID == 0 { + return errors.New("公告ID不能为空") + } + if err := validateAnnouncement(item); err != nil { + return err + } + return uc.repo.Update(ctx, item) +} + +func (uc *AnnouncementUsecase) Delete(ctx context.Context, id uint) error { + if id == 0 { + return errors.New("公告ID不能为空") + } + return uc.repo.Delete(ctx, id) +} + +func (uc *AnnouncementUsecase) DeleteByIDs(ctx context.Context, ids []uint) error { + if len(ids) == 0 { + return errors.New("公告ID不能为空") + } + return uc.repo.DeleteByIDs(ctx, ids) +} + +func (uc *AnnouncementUsecase) Find(ctx context.Context, id uint) (*Announcement, error) { + if id == 0 { + return nil, errors.New("公告ID不能为空") + } + return uc.repo.Find(ctx, id) +} + +func (uc *AnnouncementUsecase) List(ctx context.Context, filter AnnouncementFilter) ([]*Announcement, int64, error) { + return uc.repo.List(ctx, filter) +} + +func (uc *AnnouncementUsecase) UserOptions(ctx context.Context) ([]UserOption, error) { + return uc.repo.UserOptions(ctx) +} diff --git a/internal/biz/biz.go b/internal/biz/biz.go index c7217b8..5d2ab99 100644 --- a/internal/biz/biz.go +++ b/internal/biz/biz.go @@ -3,4 +3,4 @@ package biz import "github.com/google/wire" // ProviderSet is biz providers. -var ProviderSet = wire.NewSet(NewAdminUsecase, NewSystemUsecase, NewAccessUsecase, NewSettingsUsecase, NewVersionUsecase, NewExportUsecase, NewAuditUsecase, NewTaskUsecase, NewMediaUsecase) +var ProviderSet = wire.NewSet(NewAdminUsecase, NewSystemUsecase, NewAccessUsecase, NewSettingsUsecase, NewVersionUsecase, NewExportUsecase, NewAuditUsecase, NewTaskUsecase, NewMediaUsecase, NewAnnouncementUsecase, NewEmailUsecase) diff --git a/internal/biz/email.go b/internal/biz/email.go new file mode 100644 index 0000000..523de5c --- /dev/null +++ b/internal/biz/email.go @@ -0,0 +1,55 @@ +package biz + +import ( + "context" + "errors" + "strings" +) + +type EmailRepo interface { + Send(context.Context, []string, string, string) error + DefaultRecipients() []string + Enabled() bool +} + +type EmailUsecase struct{ repo EmailRepo } + +func NewEmailUsecase(repo EmailRepo) *EmailUsecase { return &EmailUsecase{repo: repo} } + +func splitRecipients(value string) []string { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if recipient := strings.TrimSpace(part); recipient != "" { + result = append(result, recipient) + } + } + return result +} + +func (uc *EmailUsecase) Send(ctx context.Context, to, subject, body string) error { + recipients := splitRecipients(to) + if len(recipients) == 0 || subject == "" { + return errors.New("收件人和邮件标题不能为空") + } + return uc.repo.Send(ctx, recipients, subject, body) +} + +func (uc *EmailUsecase) Test(ctx context.Context) error { + recipients := uc.repo.DefaultRecipients() + if len(recipients) == 0 { + return errors.New("未配置测试邮件收件人") + } + return uc.repo.Send(ctx, recipients, "test", "test") +} + +func (uc *EmailUsecase) Alert(ctx context.Context, subject, body string) error { + if !uc.repo.Enabled() { + return nil + } + recipients := uc.repo.DefaultRecipients() + if len(recipients) == 0 { + return nil + } + return uc.repo.Send(ctx, recipients, subject, body) +} diff --git a/internal/conf/conf.pb.go b/internal/conf/conf.pb.go index a776afc..127b003 100644 --- a/internal/conf/conf.pb.go +++ b/internal/conf/conf.pb.go @@ -193,6 +193,7 @@ type AdminBackend struct { Jwt *AdminBackend_JWT `protobuf:"bytes,2,opt,name=jwt,proto3" json:"jwt,omitempty"` Captcha *AdminBackend_Captcha `protobuf:"bytes,3,opt,name=captcha,proto3" json:"captcha,omitempty"` Local *AdminBackend_Local `protobuf:"bytes,4,opt,name=local,proto3" json:"local,omitempty"` + Email *AdminBackend_Email `protobuf:"bytes,5,opt,name=email,proto3" json:"email,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -255,6 +256,13 @@ func (x *AdminBackend) GetLocal() *AdminBackend_Local { return nil } +func (x *AdminBackend) GetEmail() *AdminBackend_Email { + if x != nil { + return x.Email + } + return nil +} + type Server_HTTP struct { state protoimpl.MessageState `protogen:"open.v1"` Network string `protobuf:"bytes,1,opt,name=network,proto3" json:"network,omitempty"` @@ -683,6 +691,106 @@ func (x *AdminBackend_Local) GetPathPrefix() string { return "" } +type AdminBackend_Email struct { + state protoimpl.MessageState `protogen:"open.v1"` + To string `protobuf:"bytes,1,opt,name=to,proto3" json:"to,omitempty"` + From string `protobuf:"bytes,2,opt,name=from,proto3" json:"from,omitempty"` + Host string `protobuf:"bytes,3,opt,name=host,proto3" json:"host,omitempty"` + Secret string `protobuf:"bytes,4,opt,name=secret,proto3" json:"secret,omitempty"` + Nickname string `protobuf:"bytes,5,opt,name=nickname,proto3" json:"nickname,omitempty"` + Port int32 `protobuf:"varint,6,opt,name=port,proto3" json:"port,omitempty"` + IsSsl bool `protobuf:"varint,7,opt,name=is_ssl,json=isSsl,proto3" json:"is_ssl,omitempty"` + IsLoginAuth bool `protobuf:"varint,8,opt,name=is_login_auth,json=isLoginAuth,proto3" json:"is_login_auth,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AdminBackend_Email) Reset() { + *x = AdminBackend_Email{} + mi := &file_conf_conf_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AdminBackend_Email) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AdminBackend_Email) ProtoMessage() {} + +func (x *AdminBackend_Email) ProtoReflect() protoreflect.Message { + mi := &file_conf_conf_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AdminBackend_Email.ProtoReflect.Descriptor instead. +func (*AdminBackend_Email) Descriptor() ([]byte, []int) { + return file_conf_conf_proto_rawDescGZIP(), []int{3, 3} +} + +func (x *AdminBackend_Email) GetTo() string { + if x != nil { + return x.To + } + return "" +} + +func (x *AdminBackend_Email) GetFrom() string { + if x != nil { + return x.From + } + return "" +} + +func (x *AdminBackend_Email) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +func (x *AdminBackend_Email) GetSecret() string { + if x != nil { + return x.Secret + } + return "" +} + +func (x *AdminBackend_Email) GetNickname() string { + if x != nil { + return x.Nickname + } + return "" +} + +func (x *AdminBackend_Email) GetPort() int32 { + if x != nil { + return x.Port + } + return 0 +} + +func (x *AdminBackend_Email) GetIsSsl() bool { + if x != nil { + return x.IsSsl + } + return false +} + +func (x *AdminBackend_Email) GetIsLoginAuth() bool { + if x != nil { + return x.IsLoginAuth + } + return false +} + var File_conf_conf_proto protoreflect.FileDescriptor const file_conf_conf_proto_rawDesc = "" + @@ -714,12 +822,13 @@ const file_conf_conf_proto_rawDesc = "" + "\anetwork\x18\x01 \x01(\tR\anetwork\x12\x12\n" + "\x04addr\x18\x02 \x01(\tR\x04addr\x12<\n" + "\fread_timeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\vreadTimeout\x12>\n" + - "\rwrite_timeout\x18\x04 \x01(\v2\x19.google.protobuf.DurationR\fwriteTimeout\"\x82\x05\n" + + "\rwrite_timeout\x18\x04 \x01(\v2\x19.google.protobuf.DurationR\fwriteTimeout\"\xfd\x06\n" + "\fAdminBackend\x12#\n" + "\rrouter_prefix\x18\x01 \x01(\tR\frouterPrefix\x12.\n" + "\x03jwt\x18\x02 \x01(\v2\x1c.kratos.api.AdminBackend.JWTR\x03jwt\x12:\n" + "\acaptcha\x18\x03 \x01(\v2 .kratos.api.AdminBackend.CaptchaR\acaptcha\x124\n" + - "\x05local\x18\x04 \x01(\v2\x1e.kratos.api.AdminBackend.LocalR\x05local\x1a\xb8\x01\n" + + "\x05local\x18\x04 \x01(\v2\x1e.kratos.api.AdminBackend.LocalR\x05local\x124\n" + + "\x05email\x18\x05 \x01(\v2\x1e.kratos.api.AdminBackend.EmailR\x05email\x1a\xb8\x01\n" + "\x03JWT\x12\x1f\n" + "\vsigning_key\x18\x01 \x01(\tR\n" + "signingKey\x12<\n" + @@ -737,7 +846,16 @@ const file_conf_conf_proto_rawDesc = "" + "\n" + "store_path\x18\x01 \x01(\tR\tstorePath\x12\x1f\n" + "\vpath_prefix\x18\x02 \x01(\tR\n" + - "pathPrefixB\x18Z\x16kra/internal/conf;confb\x06proto3" + "pathPrefix\x1a\xc2\x01\n" + + "\x05Email\x12\x0e\n" + + "\x02to\x18\x01 \x01(\tR\x02to\x12\x12\n" + + "\x04from\x18\x02 \x01(\tR\x04from\x12\x12\n" + + "\x04host\x18\x03 \x01(\tR\x04host\x12\x16\n" + + "\x06secret\x18\x04 \x01(\tR\x06secret\x12\x1a\n" + + "\bnickname\x18\x05 \x01(\tR\bnickname\x12\x12\n" + + "\x04port\x18\x06 \x01(\x05R\x04port\x12\x15\n" + + "\x06is_ssl\x18\a \x01(\bR\x05isSsl\x12\"\n" + + "\ris_login_auth\x18\b \x01(\bR\visLoginAuthB\x18Z\x16kra/internal/conf;confb\x06proto3" var ( file_conf_conf_proto_rawDescOnce sync.Once @@ -751,7 +869,7 @@ func file_conf_conf_proto_rawDescGZIP() []byte { return file_conf_conf_proto_rawDescData } -var file_conf_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_conf_conf_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_conf_conf_proto_goTypes = []any{ (*Bootstrap)(nil), // 0: kratos.api.Bootstrap (*Server)(nil), // 1: kratos.api.Server @@ -764,7 +882,8 @@ var file_conf_conf_proto_goTypes = []any{ (*AdminBackend_JWT)(nil), // 8: kratos.api.AdminBackend.JWT (*AdminBackend_Captcha)(nil), // 9: kratos.api.AdminBackend.Captcha (*AdminBackend_Local)(nil), // 10: kratos.api.AdminBackend.Local - (*durationpb.Duration)(nil), // 11: google.protobuf.Duration + (*AdminBackend_Email)(nil), // 11: kratos.api.AdminBackend.Email + (*durationpb.Duration)(nil), // 12: google.protobuf.Duration } var file_conf_conf_proto_depIdxs = []int32{ 1, // 0: kratos.api.Bootstrap.server:type_name -> kratos.api.Server @@ -777,18 +896,19 @@ var file_conf_conf_proto_depIdxs = []int32{ 8, // 7: kratos.api.AdminBackend.jwt:type_name -> kratos.api.AdminBackend.JWT 9, // 8: kratos.api.AdminBackend.captcha:type_name -> kratos.api.AdminBackend.Captcha 10, // 9: kratos.api.AdminBackend.local:type_name -> kratos.api.AdminBackend.Local - 11, // 10: kratos.api.Server.HTTP.timeout:type_name -> google.protobuf.Duration - 11, // 11: kratos.api.Server.GRPC.timeout:type_name -> google.protobuf.Duration - 11, // 12: kratos.api.Data.Redis.read_timeout:type_name -> google.protobuf.Duration - 11, // 13: kratos.api.Data.Redis.write_timeout:type_name -> google.protobuf.Duration - 11, // 14: kratos.api.AdminBackend.JWT.expires_time:type_name -> google.protobuf.Duration - 11, // 15: kratos.api.AdminBackend.JWT.buffer_time:type_name -> google.protobuf.Duration - 11, // 16: kratos.api.AdminBackend.Captcha.store_expiration:type_name -> google.protobuf.Duration - 17, // [17:17] is the sub-list for method output_type - 17, // [17:17] is the sub-list for method input_type - 17, // [17:17] is the sub-list for extension type_name - 17, // [17:17] is the sub-list for extension extendee - 0, // [0:17] is the sub-list for field type_name + 11, // 10: kratos.api.AdminBackend.email:type_name -> kratos.api.AdminBackend.Email + 12, // 11: kratos.api.Server.HTTP.timeout:type_name -> google.protobuf.Duration + 12, // 12: kratos.api.Server.GRPC.timeout:type_name -> google.protobuf.Duration + 12, // 13: kratos.api.Data.Redis.read_timeout:type_name -> google.protobuf.Duration + 12, // 14: kratos.api.Data.Redis.write_timeout:type_name -> google.protobuf.Duration + 12, // 15: kratos.api.AdminBackend.JWT.expires_time:type_name -> google.protobuf.Duration + 12, // 16: kratos.api.AdminBackend.JWT.buffer_time:type_name -> google.protobuf.Duration + 12, // 17: kratos.api.AdminBackend.Captcha.store_expiration:type_name -> google.protobuf.Duration + 18, // [18:18] is the sub-list for method output_type + 18, // [18:18] is the sub-list for method input_type + 18, // [18:18] is the sub-list for extension type_name + 18, // [18:18] is the sub-list for extension extendee + 0, // [0:18] is the sub-list for field type_name } func init() { file_conf_conf_proto_init() } @@ -802,7 +922,7 @@ func file_conf_conf_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_conf_conf_proto_rawDesc), len(file_conf_conf_proto_rawDesc)), NumEnums: 0, - NumMessages: 11, + NumMessages: 12, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/conf/conf.proto b/internal/conf/conf.proto index ba567a4..d84ea6e 100644 --- a/internal/conf/conf.proto +++ b/internal/conf/conf.proto @@ -47,6 +47,7 @@ message AdminBackend { JWT jwt = 2; Captcha captcha = 3; Local local = 4; + Email email = 5; message JWT { string signing_key = 1; @@ -66,4 +67,15 @@ message AdminBackend { string store_path = 1; string path_prefix = 2; } + + message Email { + string to = 1; + string from = 2; + string host = 3; + string secret = 4; + string nickname = 5; + int32 port = 6; + bool is_ssl = 7; + bool is_login_auth = 8; + } } diff --git a/internal/data/announcement.go b/internal/data/announcement.go new file mode 100644 index 0000000..2445e25 --- /dev/null +++ b/internal/data/announcement.go @@ -0,0 +1,127 @@ +package data + +import ( + "context" + "encoding/json" + "errors" + "time" + + "kra/internal/biz" + + "gorm.io/gorm" +) + +type announcementPO struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Title string + Content string `gorm:"type:text"` + UserID *uint `gorm:"column:user_id"` + Attachments []byte `gorm:"type:json"` +} + +func (announcementPO) TableName() string { return "gva_announcements_info" } + +type announcementRepo struct{ data *Data } + +func NewAnnouncementRepo(data *Data) biz.AnnouncementRepo { return &announcementRepo{data: data} } + +func newAnnouncement(item *biz.Announcement) announcementPO { + attachments := []byte(item.Attachments) + if len(attachments) == 0 || !json.Valid(attachments) { + attachments = []byte("[]") + } + return announcementPO{ID: item.ID, Title: item.Title, Content: item.Content, UserID: item.UserID, Attachments: attachments} +} + +func announcementToBiz(item announcementPO) *biz.Announcement { + attachments := json.RawMessage(item.Attachments) + if len(attachments) == 0 || !json.Valid(attachments) { + attachments = json.RawMessage("[]") + } + return &biz.Announcement{ID: item.ID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, Title: item.Title, Content: item.Content, UserID: item.UserID, Attachments: attachments} +} + +func (r *announcementRepo) Create(ctx context.Context, item *biz.Announcement) error { + po := newAnnouncement(item) + if err := r.data.gormDB.WithContext(ctx).Create(&po).Error; err != nil { + return err + } + item.ID, item.CreatedAt, item.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt + return nil +} + +func (r *announcementRepo) Delete(ctx context.Context, id uint) error { + return r.data.gormDB.WithContext(ctx).Delete(&announcementPO{}, id).Error +} + +func (r *announcementRepo) DeleteByIDs(ctx context.Context, ids []uint) error { + return r.data.gormDB.WithContext(ctx).Where("id IN ?", ids).Delete(&announcementPO{}).Error +} + +func (r *announcementRepo) Update(ctx context.Context, item *biz.Announcement) error { + po := newAnnouncement(item) + result := r.data.gormDB.WithContext(ctx).Model(&announcementPO{}).Where("id = ?", item.ID).Updates(map[string]any{ + "title": po.Title, "content": po.Content, "user_id": po.UserID, "attachments": po.Attachments, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (r *announcementRepo) Find(ctx context.Context, id uint) (*biz.Announcement, error) { + var po announcementPO + if err := r.data.gormDB.WithContext(ctx).First(&po, id).Error; err != nil { + return nil, err + } + return announcementToBiz(po), nil +} + +func (r *announcementRepo) List(ctx context.Context, filter biz.AnnouncementFilter) ([]*biz.Announcement, int64, error) { + db := r.data.gormDB.WithContext(ctx).Model(&announcementPO{}) + if filter.StartCreatedAt != nil && filter.EndCreatedAt != nil { + db = db.Where("created_at BETWEEN ? AND ?", filter.StartCreatedAt, filter.EndCreatedAt) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + if filter.PageSize > 0 { + page := filter.Page + if page < 1 { + page = 1 + } + db = db.Offset((page - 1) * filter.PageSize).Limit(filter.PageSize) + } + var pos []announcementPO + if err := db.Find(&pos).Error; err != nil { + return nil, 0, err + } + items := make([]*biz.Announcement, 0, len(pos)) + for _, po := range pos { + items = append(items, announcementToBiz(po)) + } + return items, total, nil +} + +func (r *announcementRepo) UserOptions(ctx context.Context) ([]biz.UserOption, error) { + var rows []struct { + Label string + Value uint + } + err := r.data.gormDB.WithContext(ctx).Table("sys_users").Select("nick_name AS label, id AS value").Where("deleted_at IS NULL").Scan(&rows).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + items := make([]biz.UserOption, 0, len(rows)) + for _, row := range rows { + items = append(items, biz.UserOption{Label: row.Label, Value: row.Value}) + } + return items, nil +} diff --git a/internal/data/data.go b/internal/data/data.go index 5e00bab..d7db429 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -18,7 +18,7 @@ import ( ) // ProviderSet is data providers. -var ProviderSet = wire.NewSet(NewData, NewAdminRepo, NewSystemRepo, NewAccessRepo, NewSettingsRepo, NewVersionRepo, NewExportRepo, NewAuditRepo, NewTaskRepo, NewMediaRepo, NewCache, NewFileStorage) +var ProviderSet = wire.NewSet(NewData, NewAdminRepo, NewSystemRepo, NewAccessRepo, NewSettingsRepo, NewVersionRepo, NewExportRepo, NewAuditRepo, NewTaskRepo, NewMediaRepo, NewAnnouncementRepo, NewEmailRepo, NewCache, NewFileStorage) // Data is a struct that contains the database client. type Data struct { @@ -41,6 +41,12 @@ func NewData(c *conf.Data) (*Data, func(), error) { _ = db.Close() return nil, nil, err } + // The announcement module owns its table lifecycle independently of the + // first-run database initializer. + if err = gormDB.AutoMigrate(&announcementPO{}); err != nil { + _ = db.Close() + return nil, nil, fmt.Errorf("migrate announcement table: %w", err) + } var redisClient *redis.Client if c.Redis != nil && c.Redis.Addr != "" { options := &redis.Options{Addr: c.Redis.Addr} diff --git a/internal/data/email.go b/internal/data/email.go new file mode 100644 index 0000000..788d118 --- /dev/null +++ b/internal/data/email.go @@ -0,0 +1,149 @@ +package data + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "mime" + "net" + "net/smtp" + "strings" + "time" + + "kra/internal/biz" + "kra/internal/conf" +) + +type emailRepo struct { + config *conf.AdminBackend_Email +} + +func NewEmailRepo(config *conf.AdminBackend) biz.EmailRepo { + if config == nil { + return &emailRepo{} + } + return &emailRepo{config: config.Email} +} + +func (r *emailRepo) Enabled() bool { + return r.config != nil && r.config.Host != "" && r.config.From != "" && r.config.Secret != "" && r.config.Port > 0 +} + +func (r *emailRepo) DefaultRecipients() []string { + if r.config == nil { + return nil + } + parts := strings.Split(r.config.To, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if recipient := strings.TrimSpace(part); recipient != "" { + result = append(result, recipient) + } + } + return result +} + +func cleanHeader(value string) string { + return strings.NewReplacer("\r", "", "\n", "").Replace(value) +} + +func (r *emailRepo) Send(ctx context.Context, to []string, subject, body string) error { + if !r.Enabled() { + return errors.New("邮件服务未配置") + } + if len(to) == 0 { + return errors.New("收件人不能为空") + } + config := r.config + address := net.JoinHostPort(config.Host, fmt.Sprint(config.Port)) + dialer := &net.Dialer{Timeout: 10 * time.Second} + var conn net.Conn + var err error + if config.IsSsl { + conn, err = tls.DialWithDialer(dialer, "tcp", address, &tls.Config{ServerName: config.Host, MinVersion: tls.VersionTLS12}) + } else { + conn, err = dialer.DialContext(ctx, "tcp", address) + } + if err != nil { + return err + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(15 * time.Second)) + client, err := smtp.NewClient(conn, config.Host) + if err != nil { + return err + } + defer client.Close() + if !config.IsSsl { + if supported, _ := client.Extension("STARTTLS"); supported { + if err = client.StartTLS(&tls.Config{ServerName: config.Host, MinVersion: tls.VersionTLS12}); err != nil { + return err + } + } + } + var auth smtp.Auth + if config.IsLoginAuth { + auth = &loginAuth{username: config.From, password: config.Secret} + } else { + auth = smtp.PlainAuth("", config.From, config.Secret, config.Host) + } + if ok, _ := client.Extension("AUTH"); ok { + if err = client.Auth(auth); err != nil { + return err + } + } + if err = client.Mail(config.From); err != nil { + return err + } + for _, recipient := range to { + if err = client.Rcpt(recipient); err != nil { + return err + } + } + writer, err := client.Data() + if err != nil { + return err + } + fromHeader := cleanHeader(config.From) + if config.Nickname != "" { + fromHeader = fmt.Sprintf("%s <%s>", mime.QEncoding.Encode("UTF-8", cleanHeader(config.Nickname)), fromHeader) + } + toHeader := make([]string, 0, len(to)) + for _, recipient := range to { + toHeader = append(toHeader, cleanHeader(recipient)) + } + message := "From: " + fromHeader + "\r\n" + + "To: " + strings.Join(toHeader, ",") + "\r\n" + + "Subject: " + mime.QEncoding.Encode("UTF-8", cleanHeader(subject)) + "\r\n" + + "MIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n" + body + if _, err = io.WriteString(writer, message); err != nil { + _ = writer.Close() + return err + } + if err = writer.Close(); err != nil { + return err + } + return client.Quit() +} + +type loginAuth struct{ username, password string } + +func (a *loginAuth) Start(*smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", nil, nil +} + +func (a *loginAuth) Next(challenge []byte, more bool) ([]byte, error) { + if !more { + return nil, nil + } + prompt := strings.ToLower(strings.TrimSpace(string(challenge))) + if strings.Contains(prompt, "username") || strings.Contains(prompt, "user") { + return []byte(a.username), nil + } + if strings.Contains(prompt, "password") || strings.Contains(prompt, "pass") { + return []byte(a.password), nil + } + return nil, fmt.Errorf("unsupported SMTP LOGIN challenge %q", prompt) +} diff --git a/internal/data/system.go b/internal/data/system.go index 153660f..56045b8 100644 --- a/internal/data/system.go +++ b/internal/data/system.go @@ -95,7 +95,7 @@ func (r *systemRepo) IsInitialized(ctx context.Context) (bool, error) { func (r *systemRepo) Initialize(ctx context.Context) error { db := r.data.gormDB.WithContext(ctx) - if err := db.AutoMigrate(&userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &menuButtonPO{}, &authorityButtonPO{}, &departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{}, &dictionaryPO{}, &dictionaryDetailPO{}, ¶meterPO{}, &apiTokenPO{}, &securityConfigPO{}, &versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{}, &operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{}, &taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{}); err != nil { + if err := db.AutoMigrate(&userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &menuButtonPO{}, &authorityButtonPO{}, &departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{}, &dictionaryPO{}, &dictionaryDetailPO{}, ¶meterPO{}, &apiTokenPO{}, &securityConfigPO{}, &versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{}, &operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{}, &taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{}, &announcementPO{}); err != nil { return err } return db.Transaction(func(tx *gorm.DB) error { @@ -419,12 +419,13 @@ func defaultMenus() []menuPO { } return []menuPO{ {Path: "dashboard", Name: "dashboard", Component: "view/dashboard/index.vue", Title: "仪表盘", Icon: "odometer", Sort: 1}, - root("permission", "permission", "权限管理", "perm-gva", 2), root("org", "org", "组织管理", "share", 3), root("systemConfig", "systemConfig", "系统设置", "config-gva", 4), root("monitor", "monitor", "运维监控", "monitor-gva", 5), root("media", "media", "媒体管理", "folder-opened", 6), + root("permission", "permission", "权限管理", "perm-gva", 2), root("org", "org", "组织管理", "share", 3), root("systemConfig", "systemConfig", "系统设置", "config-gva", 4), root("monitor", "monitor", "运维监控", "monitor-gva", 5), root("media", "media", "媒体管理", "folder-opened", 6), root("plugin", "plugin", "插件系统", "cherry", 10), {Path: "person", Name: "person", Component: "view/person/person.vue", Title: "个人信息", Icon: "postcard", Hidden: true, Sort: 13}, child("permission", "authority", "authority", "view/superAdmin/authority/authority.vue", "角色管理", "role-gva", 1), child("permission", "menu", "menu", "view/superAdmin/menu/menu.vue", "菜单管理", "tickets", 2), child("permission", "api", "api", "view/superAdmin/api/api.vue", "api管理", "api-gva", 3), child("permission", "apiToken", "apiToken", "view/systemTools/apiToken/index.vue", "API Token", "key", 4), child("org", "user", "user", "view/superAdmin/user/user.vue", "用户管理", "user", 1), child("org", "department", "department", "view/superAdmin/department/department.vue", "部门管理", "office-building", 2), child("org", "position", "position", "view/superAdmin/position/position.vue", "岗位管理", "postcard", 3), child("systemConfig", "system", "system", "view/systemTools/system/system.vue", "配置文件", "config-file-gva", 1), child("systemConfig", "dictionary", "dictionary", "view/superAdmin/dictionary/sysDictionary.vue", "字典管理", "notebook", 2), child("systemConfig", "sysParams", "sysParams", "view/superAdmin/params/sysParams.vue", "参数管理", "set-up", 3), child("systemConfig", "security", "security", "view/system/security/index.vue", "安全配置", "security-gva", 4), child("monitor", "operation", "operation", "view/superAdmin/operation/sysOperationRecord.vue", "操作历史", "document", 1), child("monitor", "loginLog", "loginLog", "view/systemTools/loginLog/index.vue", "登录日志", "clock", 2), child("monitor", "sysError", "sysError", "view/systemTools/sysError/sysError.vue", "错误日志", "error-gva", 3), child("monitor", "sysVersion", "sysVersion", "view/systemTools/version/version.vue", "版本管理", "version-gva", 4), child("monitor", "state", "state", "view/system/state.vue", "服务器状态", "server", 5), child("monitor", "dataAccessLog", "dataAccessLog", "view/superAdmin/dataAccessLog/dataAccessLog.vue", "数据权限审计", "warning", 6), child("monitor", "timedTask", "timedTask", "view/systemTools/timedTask/index.vue", "定时任务", "timer", 7), child("monitor", "logViewer", "logViewer", "view/systemTools/logViewer/index.vue", "文件日志", "document", 8), child("media", "upload", "upload", "view/media/upload.vue", "媒体库(上传下载)", "upload", 1), child("media", "chunkUpload", "chunkUpload", "view/media/chunkUpload.vue", "大文件上传", "folder-add", 2), + child("plugin", "plugin-email", "plugin-email", "plugin/email/view/index.vue", "邮件插件", "message", 4), child("plugin", "anInfo", "anInfo", "plugin/announcement/view/info.vue", "公告管理[示例]", "bell", 5), } } diff --git a/internal/server/announcement_routes.go b/internal/server/announcement_routes.go new file mode 100644 index 0000000..ee006d5 --- /dev/null +++ b/internal/server/announcement_routes.go @@ -0,0 +1,127 @@ +package server + +import ( + "encoding/json" + "strconv" + "time" + + "kra/internal/service" + + "github.com/gin-gonic/gin" +) + +type announcementRequest struct { + ID uint `json:"ID"` + Title string `json:"title"` + Content string `json:"content"` + UserID *uint `json:"userID"` + Attachments json.RawMessage `json:"attachments"` +} + +func announcementInput(req announcementRequest) service.AnnouncementInput { + return service.AnnouncementInput{ID: req.ID, Title: req.Title, Content: req.Content, UserID: req.UserID, Attachments: req.Attachments} +} + +func registerAnnouncementRoutes(private, public *gin.RouterGroup, svc *service.AnnouncementService) { + privateInfo := private.Group("/info") + privateInfo.POST("/createInfo", func(c *gin.Context) { + var req announcementRequest + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.Create(c.Request.Context(), announcementInput(req)); err != nil { + fail(c, "创建失败:"+err.Error()) + return + } + writeResult(c, codeSuccess, gin.H{}, "创建成功") + }) + privateInfo.DELETE("/deleteInfo", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + if err := svc.Delete(c.Request.Context(), uint(id)); err != nil { + fail(c, "删除失败:"+err.Error()) + return + } + writeResult(c, codeSuccess, gin.H{}, "删除成功") + }) + privateInfo.DELETE("/deleteInfoByIds", func(c *gin.Context) { + values := c.QueryArray("IDs[]") + if len(values) == 0 { + values = c.QueryArray("IDs") + } + ids := make([]uint, 0, len(values)) + for _, value := range values { + id, err := strconv.ParseUint(value, 10, 64) + if err != nil || id == 0 { + fail(c, "参数错误") + return + } + ids = append(ids, uint(id)) + } + if err := svc.DeleteByIDs(c.Request.Context(), ids); err != nil { + fail(c, "批量删除失败:"+err.Error()) + return + } + writeResult(c, codeSuccess, gin.H{}, "批量删除成功") + }) + privateInfo.PUT("/updateInfo", func(c *gin.Context) { + var req announcementRequest + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.Update(c.Request.Context(), announcementInput(req)); err != nil { + fail(c, "更新失败:"+err.Error()) + return + } + writeResult(c, codeSuccess, gin.H{}, "更新成功") + }) + privateInfo.GET("/findInfo", func(c *gin.Context) { + id, _ := strconv.ParseUint(c.Query("ID"), 10, 64) + item, err := svc.Find(c.Request.Context(), uint(id)) + if err != nil { + fail(c, "查询失败:"+err.Error()) + return + } + okWithData(c, item) + }) + privateInfo.GET("/getInfoList", func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + start, startErr := parseAnnouncementTime(c.Query("startCreatedAt")) + end, endErr := parseAnnouncementTime(c.Query("endCreatedAt")) + if startErr != nil || endErr != nil || (start == nil) != (end == nil) { + fail(c, "创建日期范围不合法") + return + } + items, total, err := svc.List(c.Request.Context(), page, pageSize, start, end) + if err != nil { + fail(c, "获取失败:"+err.Error()) + return + } + writeResult(c, codeSuccess, pageResult{List: items, Total: total, Page: page, PageSize: pageSize}, "获取成功") + }) + publicInfo := public.Group("/info") + publicInfo.GET("/getInfoDataSource", func(c *gin.Context) { + users, err := svc.UserOptions(c.Request.Context()) + if err != nil { + fail(c, "查询失败:"+err.Error()) + return + } + okWithData(c, gin.H{"userID": users}) + }) + publicInfo.GET("/getInfoPublic", func(c *gin.Context) { + writeResult(c, codeSuccess, gin.H{"info": "不需要鉴权的公告接口信息"}, "获取成功") + }) +} + +func parseAnnouncementTime(value string) (*time.Time, error) { + if value == "" { + return nil, nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return nil, err + } + return &parsed, nil +} diff --git a/internal/server/email_routes.go b/internal/server/email_routes.go new file mode 100644 index 0000000..5349452 --- /dev/null +++ b/internal/server/email_routes.go @@ -0,0 +1,74 @@ +package server + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + "kra/internal/service" + + "github.com/gin-gonic/gin" +) + +func registerEmailRoutes(group *gin.RouterGroup, svc *service.EmailService) { + email := group.Group("/email") + email.POST("/emailTest", func(c *gin.Context) { + if err := svc.Test(c.Request.Context()); err != nil { + fail(c, "发送失败:"+err.Error()) + return + } + writeResult(c, codeSuccess, gin.H{}, "发送成功") + }) + email.POST("/sendEmail", func(c *gin.Context) { + var req struct { + To string `json:"to"` + Subject string `json:"subject"` + Body string `json:"body"` + } + if c.ShouldBindJSON(&req) != nil { + fail(c, "参数错误") + return + } + if err := svc.Send(c.Request.Context(), req.To, req.Subject, req.Body); err != nil { + fail(c, "发送失败:"+err.Error()) + return + } + writeResult(c, codeSuccess, gin.H{}, "发送成功") + }) +} + +func emailErrorAlert(svc *service.EmailService, logger *slog.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + if strings.Contains(c.Request.URL.Path, "/email/") { + c.Next() + return + } + writer := &captureWriter{ResponseWriter: c.Writer} + c.Writer = writer + started := time.Now() + c.Next() + failed := c.Writer.Status() >= 400 + if !failed { + var result struct { + Code int `json:"code"` + } + failed = json.Unmarshal(writer.body.Bytes(), &result) == nil && result.Code != codeSuccess + } + if !failed { + return + } + username := "Unknown" + if claims := currentClaims(c); claims != nil && claims.Username != "" { + username = claims.Username + } + subject := fmt.Sprintf("%s %s 调用 %s 报错", username, c.ClientIP(), c.Request.URL.Path) + body := bytes.NewBuffer(nil) + fmt.Fprintf(body, "请求方式:%s
请求路径:%s
状态码:%d
耗时:%s
错误响应:
%s
", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(started), redactJSON(writer.body.Bytes())) + if err := svc.Alert(c.Request.Context(), subject, body.String()); err != nil && logger != nil { + logger.Error("send HTTP error email", "error", err) + } + } +} diff --git a/internal/server/gin.go b/internal/server/gin.go index 6c50e10..5bf5482 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -30,10 +30,10 @@ type GinServer struct { logger *slog.Logger } -func NewGinServer(c *conf.Server, config *conf.AdminBackend, svc *service.SystemService, access *service.AccessService, settings *service.SettingsService, versions *service.VersionService, exports *service.ExportService, audit *service.AuditService, tasks *service.TaskService, media *service.MediaService, scheduler *TaskScheduler, logger *slog.Logger) *GinServer { +func NewGinServer(c *conf.Server, config *conf.AdminBackend, svc *service.SystemService, access *service.AccessService, settings *service.SettingsService, versions *service.VersionService, exports *service.ExportService, audit *service.AuditService, tasks *service.TaskService, media *service.MediaService, announcements *service.AnnouncementService, emails *service.EmailService, scheduler *TaskScheduler, logger *slog.Logger) *GinServer { gin.SetMode(gin.ReleaseMode) engine := gin.New() - engine.Use(ginRequestMeta(), gin.Recovery(), securityRateLimit(svc, settings), operationAudit(audit)) + engine.Use(ginRequestMeta(), emailErrorAlert(emails, logger), gin.Recovery(), securityRateLimit(svc, settings), operationAudit(audit)) if config != nil && config.Local != nil && config.Local.StorePath != "" { pathPrefix := "/" + strings.Trim(config.Local.PathPrefix, "/") if pathPrefix != "/" { @@ -61,6 +61,8 @@ func NewGinServer(c *conf.Server, config *conf.AdminBackend, svc *service.System registerAuditRoutes(private, public, audit) registerTaskRoutes(private, tasks, scheduler) registerMediaRoutes(private, media) + registerAnnouncementRoutes(private, public, announcements) + registerEmailRoutes(private, emails) engine.NoRoute(func(c *gin.Context) { fail(c, "请求的接口不存在") diff --git a/internal/server/system_config_routes.go b/internal/server/system_config_routes.go index a9acb75..64324d9 100644 --- a/internal/server/system_config_routes.go +++ b/internal/server/system_config_routes.go @@ -38,6 +38,7 @@ func registerSystemConfigRoutes(group *gin.RouterGroup, config *conf.AdminBacken system := group.Group("/system") system.POST("/getSystemConfig", func(c *gin.Context) { admin := gin.H{"routerPrefix": ""} + email := gin.H{} if config != nil { admin["routerPrefix"] = config.RouterPrefix if config.Jwt != nil { @@ -49,8 +50,11 @@ func registerSystemConfigRoutes(group *gin.RouterGroup, config *conf.AdminBacken if config.Local != nil { admin["local"] = gin.H{"storePath": config.Local.StorePath, "pathPrefix": config.Local.PathPrefix} } + if config.Email != nil { + email = gin.H{"to": config.Email.To, "from": config.Email.From, "host": config.Email.Host, "secret": "******", "nickname": config.Email.Nickname, "port": config.Email.Port, "is-ssl": config.Email.IsSsl, "is-loginauth": config.Email.IsLoginAuth} + } } - writeResult(c, codeSuccess, gin.H{"config": gin.H{"admin": admin}}, "获取成功") + writeResult(c, codeSuccess, gin.H{"config": gin.H{"admin": admin, "email": email}}, "获取成功") }) system.POST("/setSystemConfig", func(c *gin.Context) { var req struct { @@ -66,6 +70,16 @@ func registerSystemConfigRoutes(group *gin.RouterGroup, config *conf.AdminBacken PathPrefix string `json:"pathPrefix"` } `json:"local"` } `json:"admin"` + Email *struct { + To string `json:"to"` + From string `json:"from"` + Host string `json:"host"` + Secret string `json:"secret"` + Nickname string `json:"nickname"` + Port int32 `json:"port"` + IsSSL bool `json:"is-ssl"` + IsLoginAuth bool `json:"is-loginauth"` + } `json:"email"` } `json:"config"` } if c.ShouldBindJSON(&req) != nil { @@ -90,6 +104,18 @@ func registerSystemConfigRoutes(group *gin.RouterGroup, config *conf.AdminBacken config.Local.PathPrefix = req.Config.Admin.Local.PathPrefix } } + if config.Email != nil && req.Config.Email != nil { + config.Email.To = req.Config.Email.To + config.Email.From = req.Config.Email.From + config.Email.Host = req.Config.Email.Host + config.Email.Nickname = req.Config.Email.Nickname + config.Email.Port = req.Config.Email.Port + config.Email.IsSsl = req.Config.Email.IsSSL + config.Email.IsLoginAuth = req.Config.Email.IsLoginAuth + if req.Config.Email.Secret != "" && req.Config.Email.Secret != "******" { + config.Email.Secret = req.Config.Email.Secret + } + } } ok(c) }) diff --git a/internal/service/announcement.go b/internal/service/announcement.go new file mode 100644 index 0000000..3a90e20 --- /dev/null +++ b/internal/service/announcement.go @@ -0,0 +1,86 @@ +package service + +import ( + "context" + "encoding/json" + "time" + + "kra/internal/biz" +) + +type AnnouncementInput struct { + ID uint + Title string + Content string + UserID *uint + Attachments json.RawMessage +} + +type AnnouncementService struct{ uc *biz.AnnouncementUsecase } + +func NewAnnouncementService(uc *biz.AnnouncementUsecase) *AnnouncementService { + return &AnnouncementService{uc: uc} +} + +func announcementDTO(item *biz.Announcement) map[string]any { + attachments := any([]any{}) + if len(item.Attachments) > 0 { + _ = json.Unmarshal(item.Attachments, &attachments) + } + return map[string]any{ + "ID": item.ID, "CreatedAt": item.CreatedAt, "UpdatedAt": item.UpdatedAt, "DeletedAt": nil, + "title": item.Title, "content": item.Content, "userID": item.UserID, "attachments": attachments, + } +} + +func announcementDO(in AnnouncementInput) *biz.Announcement { + return &biz.Announcement{ID: in.ID, Title: in.Title, Content: in.Content, UserID: in.UserID, Attachments: in.Attachments} +} + +func (s *AnnouncementService) Create(ctx context.Context, in AnnouncementInput) error { + return s.uc.Create(ctx, announcementDO(in)) +} + +func (s *AnnouncementService) Update(ctx context.Context, in AnnouncementInput) error { + return s.uc.Update(ctx, announcementDO(in)) +} + +func (s *AnnouncementService) Delete(ctx context.Context, id uint) error { + return s.uc.Delete(ctx, id) +} + +func (s *AnnouncementService) DeleteByIDs(ctx context.Context, ids []uint) error { + return s.uc.DeleteByIDs(ctx, ids) +} + +func (s *AnnouncementService) Find(ctx context.Context, id uint) (map[string]any, error) { + item, err := s.uc.Find(ctx, id) + if err != nil { + return nil, err + } + return announcementDTO(item), nil +} + +func (s *AnnouncementService) List(ctx context.Context, page, pageSize int, start, end *time.Time) ([]map[string]any, int64, error) { + items, total, err := s.uc.List(ctx, biz.AnnouncementFilter{Page: page, PageSize: pageSize, StartCreatedAt: start, EndCreatedAt: end}) + if err != nil { + return nil, 0, err + } + result := make([]map[string]any, 0, len(items)) + for _, item := range items { + result = append(result, announcementDTO(item)) + } + return result, total, nil +} + +func (s *AnnouncementService) UserOptions(ctx context.Context) ([]map[string]any, error) { + items, err := s.uc.UserOptions(ctx) + if err != nil { + return nil, err + } + result := make([]map[string]any, 0, len(items)) + for _, item := range items { + result = append(result, map[string]any{"label": item.Label, "value": item.Value}) + } + return result, nil +} diff --git a/internal/service/email.go b/internal/service/email.go new file mode 100644 index 0000000..3f8051a --- /dev/null +++ b/internal/service/email.go @@ -0,0 +1,20 @@ +package service + +import ( + "context" + "kra/internal/biz" +) + +type EmailService struct{ uc *biz.EmailUsecase } + +func NewEmailService(uc *biz.EmailUsecase) *EmailService { return &EmailService{uc: uc} } + +func (s *EmailService) Test(ctx context.Context) error { return s.uc.Test(ctx) } + +func (s *EmailService) Send(ctx context.Context, to, subject, body string) error { + return s.uc.Send(ctx, to, subject, body) +} + +func (s *EmailService) Alert(ctx context.Context, subject, body string) error { + return s.uc.Alert(ctx, subject, body) +} diff --git a/internal/service/service.go b/internal/service/service.go index 9ff21fa..376da4a 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -3,4 +3,4 @@ package service import "github.com/google/wire" // ProviderSet is service providers. -var ProviderSet = wire.NewSet(NewAdminService, NewSystemService, NewAccessService, NewSettingsService, NewVersionService, NewExportService, NewAuditService, NewTaskService, NewMediaService) +var ProviderSet = wire.NewSet(NewAdminService, NewSystemService, NewAccessService, NewSettingsService, NewVersionService, NewExportService, NewAuditService, NewTaskService, NewMediaService, NewAnnouncementService, NewEmailService)