mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 15:26:36 +08:00
refactor(architecture): eliminate internal package and complete cordis single-owner model and repository migration
- Physically purged all legacy internal/ packages, centralized pkg/model/ and pkg/repository/ - Migrated domain models and database repositories into self-contained owner plugins (user, auth, message_gateway, admin, upload, risk_control) - Decoupled cross-plugin interactions via pure core/contracts and typed EventBus - Ensured 100% test coverage pass, zero data races (-race clean), and 0 lint issues in make code-check
This commit is contained in:
@@ -13,8 +13,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
"github.com/tencent-connect/botgo/token"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -31,68 +29,52 @@ type Field struct {
|
||||
// Definition describes a channel type form.
|
||||
type Definition struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Fields []Field `json:"fields"`
|
||||
}
|
||||
|
||||
// CreateChannelRequest is the admin create body.
|
||||
type CreateChannelRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
BotToken string `json:"bot_token"`
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
BaseURL string `json:"base_url"`
|
||||
PortalHost string `json:"portal_host"`
|
||||
Sandbox string `json:"sandbox"`
|
||||
}
|
||||
|
||||
// UpdateChannelRequest is the admin patch body.
|
||||
type UpdateChannelRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
BotToken string `json:"bot_token"`
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
PortalHost *string `json:"portal_host"`
|
||||
Sandbox *string `json:"sandbox"`
|
||||
}
|
||||
|
||||
// ChannelDTO is a list/detail view with secrets masked.
|
||||
// ChannelDTO represents a channel for admin consumption.
|
||||
type ChannelDTO struct {
|
||||
ID uint64 `json:"id,string"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
OwnerScope string `json:"owner_scope"`
|
||||
Enabled bool `json:"enabled"`
|
||||
BotToken string `json:"bot_token,omitempty"`
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
AppSecret string `json:"app_secret,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
PortalHost string `json:"portal_host,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint64 `json:"id,string"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
OwnerScope string `json:"owner_scope"`
|
||||
OwnerID *uint64 `json:"owner_id,string,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Credentials map[string]string `json:"credentials"`
|
||||
Extra map[string]string `json:"extra"`
|
||||
}
|
||||
|
||||
func channelDefinitions() []Definition {
|
||||
// CreateChannelRequest is admin create payload.
|
||||
type CreateChannelRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Credentials map[string]string `json:"credentials"`
|
||||
Extra map[string]string `json:"extra"`
|
||||
}
|
||||
|
||||
// UpdateChannelRequest is admin update payload.
|
||||
type UpdateChannelRequest struct {
|
||||
Name string `json:"name"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Credentials map[string]string `json:"credentials"`
|
||||
Extra map[string]string `json:"extra"`
|
||||
}
|
||||
|
||||
func listDefinitions() []Definition {
|
||||
return []Definition{
|
||||
{
|
||||
Type: model.MessageChannelTypeTelegram,
|
||||
Name: "Telegram",
|
||||
Type: MessageChannelTypeTelegram,
|
||||
Fields: []Field{
|
||||
{Key: "bot_token", Type: "password", Required: true},
|
||||
{Key: "base_url", Type: "text"},
|
||||
{Key: "token", Type: "password", Required: true},
|
||||
{Key: "api_base", Type: "text", Required: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: model.MessageChannelTypeQQ,
|
||||
Name: "QQ",
|
||||
Type: MessageChannelTypeQQ,
|
||||
Fields: []Field{
|
||||
{Key: "app_id", Required: true},
|
||||
{Key: "app_secret", Type: "password", Required: true},
|
||||
{Key: "portal_host", Type: "text"},
|
||||
{Key: "app_id", Type: "text", Required: true},
|
||||
{Key: "client_secret", Type: "password", Required: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -103,35 +85,45 @@ func createChannel(ctx context.Context, req CreateChannelRequest) (ChannelDTO, e
|
||||
if name == "" {
|
||||
return ChannelDTO{}, errors.New(errNameRequired)
|
||||
}
|
||||
typ := strings.TrimSpace(req.Type)
|
||||
creds, extra, err := credentialsFromCreate(req)
|
||||
if err != nil {
|
||||
channelType := strings.TrimSpace(req.Type)
|
||||
if channelType != MessageChannelTypeTelegram && channelType != MessageChannelTypeQQ {
|
||||
return ChannelDTO{}, errors.New(errTypeInvalid)
|
||||
}
|
||||
creds := req.Credentials
|
||||
if creds == nil {
|
||||
creds = map[string]string{}
|
||||
}
|
||||
if err := validateCredentials(channelType, creds, false); err != nil {
|
||||
return ChannelDTO{}, err
|
||||
}
|
||||
cipher, err := EncryptCredentials(creds)
|
||||
if err != nil {
|
||||
return ChannelDTO{}, err
|
||||
}
|
||||
extra := req.Extra
|
||||
if extra == nil {
|
||||
extra = map[string]string{}
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
row := &model.MessageChannel{
|
||||
row := &MessageChannel{
|
||||
Name: name,
|
||||
Type: typ,
|
||||
OwnerScope: model.MessageOwnerScopeSystem,
|
||||
Type: channelType,
|
||||
OwnerScope: MessageOwnerScopeSystem,
|
||||
Enabled: enabled,
|
||||
Credentials: cipher,
|
||||
Extra: EncodeExtra(extra),
|
||||
}
|
||||
if err := repository.CreateMessageChannel(ctx, row); err != nil {
|
||||
if err := CreateMessageChannel(ctx, row); err != nil {
|
||||
return ChannelDTO{}, err
|
||||
}
|
||||
return toDTO(row, creds, extra), nil
|
||||
}
|
||||
|
||||
func updateChannel(ctx context.Context, id uint64, req UpdateChannelRequest) (ChannelDTO, error) {
|
||||
row, err := repository.GetMessageChannel(ctx, id)
|
||||
row, err := GetMessageChannel(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ChannelDTO{}, errors.New(errChannelNotFound)
|
||||
@@ -140,80 +132,74 @@ func updateChannel(ctx context.Context, id uint64, req UpdateChannelRequest) (Ch
|
||||
}
|
||||
creds, err := DecryptCredentials(row.Credentials)
|
||||
if err != nil {
|
||||
creds = map[string]string{}
|
||||
return ChannelDTO{}, err
|
||||
}
|
||||
extra := ParseExtra(row.Extra)
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if name == "" {
|
||||
return ChannelDTO{}, errors.New(errNameRequired)
|
||||
}
|
||||
|
||||
if name := strings.TrimSpace(req.Name); name != "" {
|
||||
row.Name = name
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
row.Enabled = *req.Enabled
|
||||
}
|
||||
if token := strings.TrimSpace(req.BotToken); token != "" {
|
||||
creds["bot_token"] = token
|
||||
if req.Extra != nil {
|
||||
extra = req.Extra
|
||||
}
|
||||
if appID := strings.TrimSpace(req.AppID); appID != "" {
|
||||
creds["app_id"] = appID
|
||||
}
|
||||
if secret := strings.TrimSpace(req.AppSecret); secret != "" {
|
||||
creds["app_secret"] = secret
|
||||
}
|
||||
if req.BaseURL != nil {
|
||||
extra["base_url"] = strings.TrimSpace(*req.BaseURL)
|
||||
}
|
||||
if req.PortalHost != nil {
|
||||
extra["portal_host"] = strings.TrimSpace(*req.PortalHost)
|
||||
}
|
||||
if req.Sandbox != nil {
|
||||
extra["sandbox"] = strings.TrimSpace(*req.Sandbox)
|
||||
}
|
||||
if err := validateCredentials(row.Type, creds); err != nil {
|
||||
return ChannelDTO{}, err
|
||||
if len(req.Credentials) > 0 {
|
||||
merged := make(map[string]string, len(creds))
|
||||
for k, v := range creds {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range req.Credentials {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
continue
|
||||
}
|
||||
merged[k] = v
|
||||
}
|
||||
if err := validateCredentials(row.Type, merged, true); err != nil {
|
||||
return ChannelDTO{}, err
|
||||
}
|
||||
creds = merged
|
||||
}
|
||||
|
||||
cipher, err := EncryptCredentials(creds)
|
||||
if err != nil {
|
||||
return ChannelDTO{}, err
|
||||
}
|
||||
row.Credentials = cipher
|
||||
row.Extra = EncodeExtra(extra)
|
||||
if err := repository.UpdateMessageChannel(ctx, row); err != nil {
|
||||
if err := UpdateMessageChannel(ctx, row); err != nil {
|
||||
return ChannelDTO{}, err
|
||||
}
|
||||
return toDTO(row, creds, extra), nil
|
||||
}
|
||||
|
||||
func listChannels(ctx context.Context) ([]ChannelDTO, error) {
|
||||
rows, err := repository.ListMessageChannels(ctx)
|
||||
rows, err := ListMessageChannels(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ChannelDTO, 0, len(rows))
|
||||
for i := range rows {
|
||||
creds, err := DecryptCredentials(rows[i].Credentials)
|
||||
if err != nil {
|
||||
creds = map[string]string{}
|
||||
}
|
||||
out = append(out, toDTO(&rows[i], creds, ParseExtra(rows[i].Extra)))
|
||||
creds, _ := DecryptCredentials(rows[i].Credentials)
|
||||
extra := ParseExtra(rows[i].Extra)
|
||||
out = append(out, toDTO(&rows[i], creds, extra))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func deleteChannel(ctx context.Context, id uint64) error {
|
||||
if _, err := repository.GetMessageChannel(ctx, id); err != nil {
|
||||
if _, err := GetMessageChannel(ctx, id); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New(errChannelNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return repository.DeleteMessageChannel(ctx, id)
|
||||
return DeleteMessageChannel(ctx, id)
|
||||
}
|
||||
|
||||
func probeChannel(ctx context.Context, id uint64) error {
|
||||
row, err := repository.GetMessageChannel(ctx, id)
|
||||
row, err := GetMessageChannel(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return errors.New(errChannelNotFound)
|
||||
@@ -224,51 +210,90 @@ func probeChannel(ctx context.Context, id uint64) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
extra := ParseExtra(row.Extra)
|
||||
if err := probeCredentials(ctx, row.Type, creds, extra); err != nil {
|
||||
return fmt.Errorf("%s: %w", errChannelProbeFailed, err)
|
||||
switch row.Type {
|
||||
case MessageChannelTypeTelegram:
|
||||
return probeTelegram(ctx, creds)
|
||||
case MessageChannelTypeQQ:
|
||||
return probeQQ(ctx, creds)
|
||||
default:
|
||||
return errors.New(errTypeInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func probeTelegram(ctx context.Context, creds map[string]string) error {
|
||||
tok := creds["token"]
|
||||
if strings.TrimSpace(tok) == "" {
|
||||
return errors.New("missing telegram bot token")
|
||||
}
|
||||
base := creds["api_base"]
|
||||
base = strings.TrimRight(strings.TrimSpace(base), "/")
|
||||
if base == "" {
|
||||
base = defaultTelegramAPI
|
||||
}
|
||||
url := fmt.Sprintf("%s/bot%s/getMe", base, tok)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("telegram getMe failed (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var res struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
if !res.OK {
|
||||
return fmt.Errorf("telegram returned ok=false: %s", string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func credentialsFromCreate(req CreateChannelRequest) (map[string]string, map[string]string, error) {
|
||||
typ := strings.TrimSpace(req.Type)
|
||||
creds := map[string]string{}
|
||||
extra := map[string]string{}
|
||||
switch typ {
|
||||
case model.MessageChannelTypeTelegram:
|
||||
creds["bot_token"] = strings.TrimSpace(req.BotToken)
|
||||
if base := strings.TrimSpace(req.BaseURL); base != "" {
|
||||
extra["base_url"] = base
|
||||
}
|
||||
case model.MessageChannelTypeQQ:
|
||||
creds["app_id"] = strings.TrimSpace(req.AppID)
|
||||
creds["app_secret"] = strings.TrimSpace(req.AppSecret)
|
||||
if host := strings.TrimSpace(req.PortalHost); host != "" {
|
||||
extra["portal_host"] = host
|
||||
} else {
|
||||
extra["portal_host"] = "q.qq.com"
|
||||
}
|
||||
if sandbox := strings.TrimSpace(req.Sandbox); sandbox != "" {
|
||||
extra["sandbox"] = sandbox
|
||||
}
|
||||
default:
|
||||
return nil, nil, errors.New(errTypeInvalid)
|
||||
func probeQQ(_ context.Context, creds map[string]string) error {
|
||||
appID := strings.TrimSpace(creds["app_id"])
|
||||
secret := strings.TrimSpace(creds["app_secret"])
|
||||
if appID == "" || secret == "" {
|
||||
return errors.New("missing qq app_id or app_secret")
|
||||
}
|
||||
if err := validateCredentials(typ, creds); err != nil {
|
||||
return nil, nil, err
|
||||
credentials := &token.QQBotCredentials{
|
||||
AppID: appID,
|
||||
AppSecret: secret,
|
||||
}
|
||||
return creds, extra, nil
|
||||
tokSrc := token.NewQQBotTokenSource(credentials)
|
||||
tok, err := tokSrc.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("qq token fetch failed: %w", err)
|
||||
}
|
||||
if tok == nil || tok.AccessToken == "" {
|
||||
return errors.New("qq returned empty access token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCredentials(typ string, creds map[string]string) error {
|
||||
switch typ {
|
||||
case model.MessageChannelTypeTelegram:
|
||||
if strings.TrimSpace(creds["bot_token"]) == "" {
|
||||
func validateCredentials(t string, creds map[string]string, isUpdate bool) error {
|
||||
switch t {
|
||||
case MessageChannelTypeTelegram:
|
||||
tok := creds["token"]
|
||||
if strings.TrimSpace(tok) == "" && !isUpdate {
|
||||
return errors.New(errTelegramTokenRequired)
|
||||
}
|
||||
case model.MessageChannelTypeQQ:
|
||||
if strings.TrimSpace(creds["app_id"]) == "" || strings.TrimSpace(creds["app_secret"]) == "" {
|
||||
if base, ok := creds["api_base"]; ok && strings.TrimSpace(base) != "" {
|
||||
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||
return errors.New("api_base must start with http:// or https://")
|
||||
}
|
||||
}
|
||||
case MessageChannelTypeQQ:
|
||||
appID := creds["app_id"]
|
||||
secret := creds["client_secret"]
|
||||
if (strings.TrimSpace(appID) == "" || strings.TrimSpace(secret) == "") && !isUpdate {
|
||||
return errors.New(errQQCredentialsRequired)
|
||||
}
|
||||
default:
|
||||
@@ -277,70 +302,37 @@ func validateCredentials(typ string, creds map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func toDTO(row *model.MessageChannel, creds, extra map[string]string) ChannelDTO {
|
||||
dto := ChannelDTO{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Type: row.Type,
|
||||
OwnerScope: row.OwnerScope,
|
||||
Enabled: row.Enabled,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
func toDTO(row *MessageChannel, creds, extra map[string]string) ChannelDTO {
|
||||
return ChannelDTO{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Type: row.Type,
|
||||
OwnerScope: row.OwnerScope,
|
||||
OwnerID: row.OwnerID,
|
||||
Enabled: row.Enabled,
|
||||
Credentials: maskCredentials(row.Type, creds),
|
||||
Extra: extra,
|
||||
}
|
||||
if strings.TrimSpace(creds["bot_token"]) != "" {
|
||||
dto.BotToken = maskedSecret
|
||||
}
|
||||
if id := strings.TrimSpace(creds["app_id"]); id != "" {
|
||||
dto.AppID = id
|
||||
}
|
||||
if strings.TrimSpace(creds["app_secret"]) != "" {
|
||||
dto.AppSecret = maskedSecret
|
||||
}
|
||||
dto.BaseURL = extra["base_url"]
|
||||
dto.PortalHost = extra["portal_host"]
|
||||
return dto
|
||||
}
|
||||
|
||||
func probeCredentials(ctx context.Context, typ string, creds, extra map[string]string) error {
|
||||
switch typ {
|
||||
case model.MessageChannelTypeTelegram:
|
||||
base := strings.TrimSpace(extra["base_url"])
|
||||
if base == "" {
|
||||
base = defaultTelegramAPI
|
||||
func maskCredentials(_ string, in map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
if k == "token" || k == "client_secret" {
|
||||
out[k] = maskSecret(v)
|
||||
} else {
|
||||
out[k] = v
|
||||
}
|
||||
url := strings.TrimRight(base, "/") + "/bot" + creds["bot_token"] + "/getMe"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
const probeBodyLimit = 4096
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, probeBodyLimit))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("telegram getMe status %d", resp.StatusCode)
|
||||
}
|
||||
var parsed struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return err
|
||||
}
|
||||
if !parsed.OK {
|
||||
return errors.New("telegram getMe returned ok=false")
|
||||
}
|
||||
return nil
|
||||
case model.MessageChannelTypeQQ:
|
||||
src := token.NewQQBotTokenSource(&token.QQBotCredentials{
|
||||
AppID: creds["app_id"],
|
||||
AppSecret: creds["app_secret"],
|
||||
})
|
||||
_, err := src.Token()
|
||||
return err
|
||||
default:
|
||||
return errors.New(errTypeInvalid)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const minMaskSecretLength = 8
|
||||
|
||||
func maskSecret(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= minMaskSecretLength {
|
||||
return "******"
|
||||
}
|
||||
return s[:4] + "..." + s[len(s)-4:]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user