迁移公告,邮件
This commit is contained in:
parent
21c264fcd7
commit
9044bafdff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`;未配置服务器、发件人或密钥时错误告警保持关闭,邮件接口会返回明确的配置错误。
|
||||
|
||||
## 验证命令
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<br>请求路径:%s<br>状态码:%d<br>耗时:%s<br>错误响应:<pre>%s</pre>", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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, "请求的接口不存在")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue