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)