// Package channel 实现多渠道接入:通用 Webhook + 企业微信/钉钉/飞书。 // 入站消息经绑定模型应答后,按平台协议回发。 package channel import ( "bytes" "context" "crypto/sha1" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "sort" "strings" "time" platformid "aigateway.local/core/internal/platform/id" "aigateway.local/core/internal/platform/oplog" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ( ErrNotFound = errors.New("渠道不存在") ErrUnavailable = errors.New("渠道服务不可用") ) // Channel 是一条渠道配置。 type Channel struct { ID string `json:"id"` Code string `json:"code"` Name string `json:"name"` Kind string `json:"kind"` Config json.RawMessage `json:"config,omitempty"` EncryptedConfig []byte `json:"-"` ConfigKEKVersion int `json:"-"` ModelBinding json.RawMessage `json:"model_binding"` DepartmentIDs []string `json:"department_ids"` HasAPIKey bool `json:"has_api_key"` Enabled bool `json:"enabled"` CreatedBy *string `json:"created_by,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Config 是渠道的平台配置(明文,仅内部使用)。 type Config struct { // 通用 InboundToken string `json:"inbound_token,omitempty"` // webhook 鉴权令牌 // 企业微信(自建应用回调 + 主动发送) CorpID string `json:"corp_id,omitempty"` Secret string `json:"secret,omitempty"` AgentID string `json:"agent_id,omitempty"` // 钉钉(机器人 webhook) DingRobotToken string `json:"ding_robot_token,omitempty"` // 飞书(应用) FeishuAppID string `json:"feishu_app_id,omitempty"` FeishuAppSecret string `json:"feishu_app_secret,omitempty"` } // Service 渠道管理 + 消息收发。 type Service struct { pool *pgxpool.Pool gatewayURL string client *http.Client logger *slog.Logger cipher interface { Encrypt([]byte) ([]byte, int, error) Decrypt([]byte, int) ([]byte, error) } } // NewService 创建渠道服务;cipher 加密平台配置与 API Key。 func NewService(pool *pgxpool.Pool, gatewayURL string, cipher interface { Encrypt([]byte) ([]byte, int, error) Decrypt([]byte, int) ([]byte, error) }, logger *slog.Logger) *Service { return &Service{ pool: pool, gatewayURL: strings.TrimRight(gatewayURL, "/"), logger: logger, cipher: cipher, client: &http.Client{Timeout: 60 * time.Second}, } } const channelSelect = `SELECT id::text,code,name,kind,encrypted_config,config_kek_version,model_binding,department_ids::text[],octet_length(encrypted_api_key)>0,enabled,created_by::text,created_at,updated_at FROM gateway.channels` func (s *Service) scan(row pgx.Row) (Channel, error) { var c Channel var createdBy *string err := row.Scan(&c.ID, &c.Code, &c.Name, &c.Kind, &c.EncryptedConfig, &c.ConfigKEKVersion, &c.ModelBinding, &c.DepartmentIDs, &c.HasAPIKey, &c.Enabled, &createdBy, &c.CreatedAt, &c.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return Channel{}, ErrNotFound } if err != nil { return Channel{}, err } c.CreatedBy = createdBy return c, nil } // List 返回渠道列表(不含敏感配置)。安全上限 200,管理端一次性渲染。 func (s *Service) List(ctx context.Context) ([]Channel, error) { if s == nil || s.pool == nil { return nil, ErrUnavailable } rows, err := s.pool.Query(ctx, channelSelect+` ORDER BY updated_at DESC LIMIT 200`) if err != nil { return nil, err } defer rows.Close() items := []Channel{} for rows.Next() { c, err := s.scan(rows) if err != nil { return nil, err } items = append(items, c) } return items, rows.Err() } // GetByCode 供入站分发使用(无需解密配置)。 func (s *Service) GetByCode(ctx context.Context, code string) (Channel, error) { if s == nil || s.pool == nil { return Channel{}, ErrUnavailable } return s.scan(s.pool.QueryRow(ctx, channelSelect+` WHERE code=$1 AND enabled`, code)) } // DecryptConfig 解密平台配置。 func (s *Service) DecryptConfig(c Channel) (Config, error) { var cfg Config if len(c.EncryptedConfig) == 0 { return cfg, nil } plaintext, err := s.cipher.Decrypt(c.EncryptedConfig, c.ConfigKEKVersion) if err != nil { return cfg, err } if err := json.Unmarshal(plaintext, &cfg); err != nil { return cfg, err } return cfg, nil } // Save 创建/更新渠道。 func (s *Service) Save(ctx context.Context, id, code, name, kind string, cfg Config, modelBinding json.RawMessage, departmentIDs []string, apiKey string, enabled bool, actorID string) (Channel, error) { if s == nil || s.pool == nil || s.cipher == nil { return Channel{}, ErrUnavailable } code = strings.ToLower(strings.TrimSpace(code)) name = strings.TrimSpace(name) if !codePattern.MatchString(code) || name == "" || len(name) > 128 { return Channel{}, errors.New("渠道代码或名称无效") } switch kind { case "webhook", "wecom", "dingtalk", "feishu": default: return Channel{}, errors.New("渠道类型必须是 webhook/wecom/dingtalk/feishu") } if id == "" { newID, err := platformid.NewUUID() if err != nil { return Channel{}, err } id = newID } encryptedConfig, configVersion, err := s.cipher.Encrypt(mustJSON(cfg)) if err != nil { return Channel{}, err } var encryptedKey []byte = []byte{} var keyVersion int if apiKey != "" { encryptedKey, keyVersion, err = s.cipher.Encrypt([]byte(apiKey)) if err != nil { return Channel{}, err } } if modelBinding == nil { modelBinding = json.RawMessage(`{}`) } if departmentIDs == nil { departmentIDs = []string{} } _, err = s.pool.Exec(ctx, `INSERT INTO gateway.channels(id,code,name,kind,encrypted_config,config_kek_version,encrypted_api_key,api_key_kek_version,model_binding,department_ids,enabled,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT(code) DO UPDATE SET name=$3,kind=$4,encrypted_config=$5,config_kek_version=$6, encrypted_api_key=CASE WHEN $7<>'' THEN $7 ELSE gateway.channels.encrypted_api_key END, api_key_kek_version=CASE WHEN $7<>'' THEN $8 ELSE gateway.channels.api_key_kek_version END, model_binding=$9,department_ids=$10,enabled=$11,updated_at=clock_timestamp()`, id, code, name, kind, encryptedConfig, configVersion, encryptedKey, keyVersion, modelBinding, departmentIDs, enabled, actorID) if err != nil { return Channel{}, err } oplog.Record(ctx, s.pool, s.logger, actorID, "", "channel.save", "channel", id, map[string]any{ "code": code, "name": name, "kind": kind, "enabled": enabled, "department_ids": departmentIDs, "has_api_key": apiKey != "", }) return s.scan(s.pool.QueryRow(ctx, channelSelect+` WHERE id=$1`, id)) } // Delete 删除渠道。 func (s *Service) Delete(ctx context.Context, id, actorID string) error { if s == nil || s.pool == nil { return ErrUnavailable } tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.channels WHERE id=$1`, id) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } oplog.Record(ctx, s.pool, s.logger, actorID, "", "channel.delete", "channel", id, nil) return nil } // DecryptAPIKey 解密渠道绑定的网关 API Key(入站调用用)。 func (s *Service) DecryptAPIKey(ctx context.Context, c Channel) (string, error) { if !c.HasAPIKey { return "", nil } var encrypted []byte var version int if err := s.pool.QueryRow(ctx, `SELECT encrypted_api_key,api_key_kek_version FROM gateway.channels WHERE id=$1`, c.ID).Scan(&encrypted, &version); err != nil { return "", err } plaintext, err := s.cipher.Decrypt(encrypted, version) if err != nil { return "", err } return string(plaintext), nil } func mustJSON(value any) []byte { raw, _ := json.Marshal(value) return raw } var codePattern = regexpMust(`^[a-z][a-z0-9_-]{2,63}$`) // InboundMessage 是归一化的入站消息。 type InboundMessage struct { FromUser string Text string } // HandleInbound 处理入站消息:调用绑定模型,按平台回复。 func (s *Service) HandleInbound(ctx context.Context, c Channel, msg InboundMessage) (string, error) { if s == nil || s.gatewayURL == "" { return "", ErrUnavailable } apiKey, err := s.DecryptAPIKey(ctx, c) if err != nil { return "", err } var binding struct { Provider string `json:"provider"` Model string `json:"model"` } _ = json.Unmarshal(c.ModelBinding, &binding) if binding.Model == "" { binding.Model = "gpt-4o-mini" } payload, _ := json.Marshal(map[string]any{ "model": binding.Model, "messages": []map[string]any{ {"role": "user", "content": msg.Text}, }, }) request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.gatewayURL+"/v1/chat/completions", bytes.NewReader(payload)) if err != nil { return "", err } request.Header.Set("Content-Type", "application/json") if apiKey != "" { request.Header.Set("Authorization", "Bearer "+apiKey) } response, err := s.client.Do(request) if err != nil { return "", err } defer response.Body.Close() raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) if response.StatusCode/100 != 2 { return "", fmt.Errorf("模型调用失败(HTTP %d)", response.StatusCode) } var decoded struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if json.Unmarshal(raw, &decoded) != nil || len(decoded.Choices) == 0 { return "", errors.New("模型响应格式无效") } return decoded.Choices[0].Message.Content, nil } // Reply 按平台协议发送回复。webhook 渠道返回同步回复文本。 func (s *Service) Reply(ctx context.Context, c Channel, cfg Config, text string) error { switch c.Kind { case "webhook": return nil // 同步返回 case "wecom": return s.replyWeCom(ctx, cfg, text) case "dingtalk": return s.replyDingTalk(ctx, cfg, text) case "feishu": return s.replyFeishu(ctx, cfg, text) default: return errors.New("不支持的渠道类型") } } // replyWeCom 通过企业微信应用消息接口发送。 func (s *Service) replyWeCom(ctx context.Context, cfg Config, text string) error { token, err := s.weComToken(ctx, cfg) if err != nil { return err } payload, _ := json.Marshal(map[string]any{ "touser": "@all", "msgtype": "text", "agentid": cfg.AgentID, "text": map[string]string{"content": text}, }) return s.postJSON(ctx, "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token="+token, payload) } func (s *Service) weComToken(ctx context.Context, cfg Config) (string, error) { endpoint := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s", url.QueryEscape(cfg.CorpID), url.QueryEscape(cfg.Secret)) response, err := s.client.Get(endpoint) if err != nil { return "", err } defer response.Body.Close() raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<16)) var decoded struct { ErrCode int `json:"errcode"` Token string `json:"access_token"` } if json.Unmarshal(raw, &decoded) != nil || decoded.ErrCode != 0 || decoded.Token == "" { return "", errors.New("企业微信 token 获取失败") } return decoded.Token, nil } // replyDingTalk 通过钉钉自定义机器人 webhook 发送。 func (s *Service) replyDingTalk(ctx context.Context, cfg Config, text string) error { payload, _ := json.Marshal(map[string]any{ "msgtype": "text", "text": map[string]string{"content": text}, }) endpoint := "https://oapi.dingtalk.com/robot/send?access_token=" + url.QueryEscape(cfg.DingRobotToken) return s.postJSON(ctx, endpoint, payload) } // replyFeishu 通过飞书机器人消息接口发送。 func (s *Service) replyFeishu(ctx context.Context, cfg Config, text string) error { token, err := s.feishuToken(ctx, cfg) if err != nil { return err } payload, _ := json.Marshal(map[string]any{ "receive_id": "@all", "msg_type": "text", "content": map[string]string{"text": text}, }) return s.postJSON(ctx, "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", payload, func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) }) } func (s *Service) feishuToken(ctx context.Context, cfg Config) (string, error) { payload, _ := json.Marshal(map[string]string{"app_id": cfg.FeishuAppID, "app_secret": cfg.FeishuAppSecret}) response, err := s.client.Post("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", "application/json", bytes.NewReader(payload)) if err != nil { return "", err } defer response.Body.Close() raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<16)) var decoded struct { Code int `json:"code"` Token string `json:"tenant_access_token"` } if json.Unmarshal(raw, &decoded) != nil || decoded.Code != 0 || decoded.Token == "" { return "", errors.New("飞书 token 获取失败") } return decoded.Token, nil } func (s *Service) postJSON(ctx context.Context, endpoint string, payload []byte, options ...func(*http.Request)) error { request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) if err != nil { return err } request.Header.Set("Content-Type", "application/json") for _, option := range options { option(request) } response, err := s.client.Do(request) if err != nil { return err } defer response.Body.Close() raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<16)) if response.StatusCode/100 != 2 { return fmt.Errorf("平台接口返回 HTTP %d", response.StatusCode) } var envelope struct { ErrCode int `json:"errcode"` Code int `json:"code"` } _ = json.Unmarshal(raw, &envelope) if envelope.ErrCode != 0 || envelope.Code != 0 { return fmt.Errorf("平台接口返回错误码 %d/%d", envelope.ErrCode, envelope.Code) } return nil } // VerifyWeComSignature 校验企业微信回调签名。 // 官方算法:token、timestamp、nonce、echostr(或加密消息)四个参数按字典序 // 排序后拼接,取 SHA1 十六进制与 msg_signature 比较。values 用于带密文消息 // 体校验时补充参与签名计算的参数。 func VerifyWeComSignature(token, timestamp, nonce, echostr string, values map[string]string) (string, bool) { parts := []string{token, timestamp, nonce, echostr} if values != nil { keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { parts = append(parts, key+"="+values[key]) } } sort.Strings(parts) sum := sha1.Sum([]byte(strings.Join(parts, ""))) if hex.EncodeToString(sum[:]) != echostr { return "", false } return echostr, true } // DingSign 计算钉钉机器人加签(时间戳+密钥)。 func DingSign(timestamp int64, secret string) string { sum := sha256.Sum256([]byte(fmt.Sprintf("%d\n%s", timestamp, secret))) return url.QueryEscape(hex.EncodeToString(sum[:])) } // ChannelGrant 是一条用户级渠道授权。 type ChannelGrant struct { ChannelID string `json:"channel_id"` ChannelCode string `json:"channel_code"` ChannelName string `json:"channel_name"` UserID string `json:"portal_user_id"` UserLogin string `json:"user_login"` Source string `json:"source"` GrantedAt time.Time `json:"created_at"` } // ListGrants 返回渠道的用户授权列表。 func (s *Service) ListGrants(ctx context.Context, channelID string) ([]ChannelGrant, error) { if s == nil || s.pool == nil { return nil, ErrUnavailable } rows, err := s.pool.Query(ctx, `SELECT g.channel_id::text,c.code,c.name,g.portal_user_id::text,u.account,g.source,g.created_at FROM gateway.channel_grants g JOIN gateway.channels c ON c.id=g.channel_id JOIN gateway.portal_users u ON u.id=g.portal_user_id WHERE g.channel_id=$1 ORDER BY g.created_at DESC`, channelID) if err != nil { return nil, err } defer rows.Close() items := []ChannelGrant{} for rows.Next() { var item ChannelGrant if err := rows.Scan(&item.ChannelID, &item.ChannelCode, &item.ChannelName, &item.UserID, &item.UserLogin, &item.Source, &item.GrantedAt); err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } // Grant 授予用户渠道使用权限(幂等)。source: manual(管理员直接授予)或 // approval(资源申请审批通过自动写入)。 func (s *Service) Grant(ctx context.Context, channelID, portalUserID, actorID, source string) error { if s == nil || s.pool == nil { return ErrUnavailable } if source != "manual" && source != "approval" { source = "manual" } var exists bool if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.portal_users WHERE id=$1)`, portalUserID).Scan(&exists); err != nil { return err } if !exists { return ErrNotFound } tag, err := s.pool.Exec(ctx, `INSERT INTO gateway.channel_grants(channel_id,portal_user_id,granted_by,source) VALUES($1,$2,nullif($3,'')::uuid,$4) ON CONFLICT DO NOTHING`, channelID, portalUserID, actorID, source) if err != nil { return err } // 幂等重复授权(已存在)不重复落审计。 if tag.RowsAffected() > 0 { oplog.Record(ctx, s.pool, s.logger, actorID, "", "channel.grant", "channel", channelID, map[string]any{"portal_user_id": portalUserID, "source": source}) } return nil } // RevokeGrant 撤销用户的渠道使用权限。 func (s *Service) RevokeGrant(ctx context.Context, channelID, portalUserID, actorID string) error { if s == nil || s.pool == nil { return ErrUnavailable } tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.channel_grants WHERE channel_id=$1 AND portal_user_id=$2`, channelID, portalUserID) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } oplog.Record(ctx, s.pool, s.logger, actorID, "", "channel.revoke_grant", "channel", channelID, map[string]any{"portal_user_id": portalUserID}) return nil } // GrantsForUser 返回用户有明确授权的渠道(供门户"我的渠道")。 func (s *Service) GrantsForUser(ctx context.Context, portalUserID string) ([]Channel, error) { if s == nil || s.pool == nil { return nil, ErrUnavailable } rows, err := s.pool.Query(ctx, `SELECT c.* FROM (`+channelSelect+`) c JOIN gateway.channel_grants g ON g.channel_id=c.id::uuid AND g.portal_user_id=$1::uuid WHERE c.enabled ORDER BY c.updated_at DESC`, portalUserID) if err != nil { return nil, err } defer rows.Close() items := []Channel{} for rows.Next() { c, err := s.scan(rows) if err != nil { return nil, err } items = append(items, c) } return items, rows.Err() } // VisibleChannelsForUser 返回用户可见渠道:部门可见(全局或本部门)或明确授权。 func (s *Service) VisibleChannelsForUser(ctx context.Context, portalUserID string, departmentID *string) ([]Channel, error) { if s == nil || s.pool == nil { return nil, ErrUnavailable } rows, err := s.pool.Query(ctx, `SELECT c.* FROM (`+channelSelect+`) c WHERE c.enabled AND ( cardinality(c.department_ids)=0 OR ($2::text = ANY(c.department_ids)) OR EXISTS(SELECT 1 FROM gateway.channel_grants g WHERE g.channel_id=c.id::uuid AND g.portal_user_id=$1::uuid) ) ORDER BY c.updated_at DESC`, portalUserID, departmentID) if err != nil { return nil, err } defer rows.Close() items := []Channel{} for rows.Next() { c, err := s.scan(rows) if err != nil { return nil, err } items = append(items, c) } return items, rows.Err() }