0.11.2: 旗舰版第三轮完善(通用聊天/企微钉钉飞书扫码登录/个人安全策略)
- 门户通用聊天:选择已批准模型直接对话,审批通过后自动开通用户级运行时 API Key(加密落库,限额取批准值),聊天经受管网关统一认证/限流/配额/审计; 会话哈希链完整性 + busy 租约防并发,失败不落库。 - 扫码登录:identity_providers 扩展 wecom/dingtalk/feishu,管理端配置 (AppID/AppSecret/AgentID/回调/自动开户/默认部门),登录页自动展示; one-time state 防 CSRF,provider_uid 全局唯一防多账号绑定,平台端点 固定公网 URL 复用 public-only 拨号。 - 个人安全策略:账号安全页(登录设备管理/吊销非当前会话/登录提醒开关/ 扫码绑定解绑),登录成功发布 security.login_detected 事件按偏好落站内信 (新增 security 类别),会话索引只存令牌摘要并惰性清理。 - 迁移 000038-000041;修复 social update 参数越界/凭据回读/路由挂载缺失; 全量测试 25 包通过,前端 admin/portal 构建通过,端到端验证完成。
This commit is contained in:
+30
@@ -18,3 +18,33 @@ Excluded:
|
||||
|
||||
Start with `deploy/PRODUCTION.md`. Verify the accompanying ZIP checksum before
|
||||
copying the bundle to a deployment host.
|
||||
|
||||
## 0.11.2 — 旗舰版第三轮完善
|
||||
|
||||
发布时间:2026-08-13
|
||||
|
||||
新增功能:
|
||||
|
||||
- **门户通用聊天**:门户工作台新增「通用聊天」页面,选择已批准模型直接对话,
|
||||
支持会话管理(新建/改名/删除/历史)。模型权限申请审批通过后,系统自动为该
|
||||
用户开通一把 gateway API Key(加密落库 `portal_user_runtime_credentials`,
|
||||
限额取批准申请值),聊天调用经受管网关执行,认证/限流/配额/审计与外部
|
||||
API Key 完全同权;会话消息沿用哈希链完整性校验与 busy 租约防并发。
|
||||
- **企微/钉钉/飞书扫码登录**:`identity_providers` 表扩展 wecom/dingtalk/feishu
|
||||
kind,管理端「账号与权限 → 扫码登录」配置身份源(AppID/AppSecret/AgentID、
|
||||
回调 URL、自动开户、默认部门)。登录页自动展示已启用身份源按钮,回调经
|
||||
one-time state 防 CSRF,支持三种平台 code 换取身份协议;未绑定账号默认
|
||||
需先在「账号安全」扫码绑定(同一平台账号全局唯一绑定),可开启自动开户。
|
||||
- **个人安全策略**:门户「账号安全」页面提供登录设备管理(IP/UA/时间,可
|
||||
吊销任意非当前会话)、新设备登录提醒开关(默认开启,经 outbox → 站内信,
|
||||
可单独关闭)、扫码登录绑定/解绑。
|
||||
|
||||
迁移:000038_portal_chat / 000039_social_login / 000040_security_prefs /
|
||||
000041_inbox_security_category(共 41 个迁移)。
|
||||
|
||||
安全要点:
|
||||
|
||||
- 扫码登录 state 为 128-bit 一次性令牌(5 分钟),回调原子消费;
|
||||
provider_uid 全局唯一,防止平台账号同时绑定多个本系统账号。
|
||||
- 会话索引只存令牌 SHA-256 摘要,列表时惰性清理过期项;当前会话不可吊销。
|
||||
- 登录提醒事件只落站内信,不包含凭据;开关按账号独立生效。
|
||||
|
||||
@@ -358,6 +358,7 @@ func main() {
|
||||
portalService := portal.NewService(db, workbenchService, toolService, identityService)
|
||||
portalService.SetEnvVarService(envVarService)
|
||||
portalService.SetApplicationRuntime(portal.NewRuntimeCredentials(db, apiKeyRepository, applicationKeyCipher), workbenchRuntime)
|
||||
portalService.SetGateway(governedGateway)
|
||||
portalService.SetMarketplace(marketplaceService)
|
||||
portalHandler := portal.NewHTTPHandler(portalService, identityService)
|
||||
portalAdminHandler := portal.NewAdminHTTPHandler(portalService, identityService)
|
||||
@@ -456,6 +457,8 @@ func main() {
|
||||
controlMux.Handle("/api/v1/admin/identity-providers/", identityManagementHandler)
|
||||
controlMux.Handle("/api/v1/admin/saml-providers", identityManagementHandler)
|
||||
controlMux.Handle("/api/v1/admin/saml-providers/", identityManagementHandler)
|
||||
controlMux.Handle("/api/v1/admin/social-providers", identityManagementHandler)
|
||||
controlMux.Handle("/api/v1/admin/social-providers/", identityManagementHandler)
|
||||
controlMux.Handle("/api/v1/portal/applications", portalHandler)
|
||||
controlMux.Handle("/api/v1/portal/apps/", portalHandler)
|
||||
controlMux.Handle("/api/v1/portal/catalog", portalHandler)
|
||||
@@ -475,6 +478,7 @@ func main() {
|
||||
controlMux.Handle("/api/v1/portal/prompts/", portalHandler)
|
||||
controlMux.Handle("/api/v1/portal/stats", portalHandler)
|
||||
controlMux.Handle("/api/v1/portal/tools", portalHandler)
|
||||
controlMux.Handle("/api/v1/portal/chat/", portalHandler)
|
||||
controlMux.Handle("/api/v1/", identityHandler)
|
||||
publicMux := http.NewServeMux()
|
||||
publicMux.Handle("/v1/prompts", workbenchRuntime)
|
||||
|
||||
@@ -426,3 +426,31 @@ PostgreSQL `text` 列拒绝写入 → 重试循环永远失败;inbox 的 Go `l
|
||||
`POST /v1/channels/{code}/inbound`(企微签名校验/钉钉加签工具),
|
||||
绑定模型应答后按平台协议回复(企微应用消息/钉钉机器人/飞书应用),
|
||||
管理端渠道 CRUD + 连通性测试。
|
||||
|
||||
# 追加:旗舰版功能完善第三轮(0.11.2,2026-08-13)
|
||||
|
||||
1. **门户通用聊天**(迁移 000038 + `internal/portal/chat.go`):
|
||||
门户「通用聊天」页选择已批准模型直接对话。模型权限审批通过后按需自动
|
||||
开通用户级运行时 API Key(`portal_user_runtime_credentials` 加密落库,
|
||||
限额取批准申请值),聊天请求经 `SetGateway` 注入的受管网关执行,与外部
|
||||
Key 同权受审计/配额约束。会话复用哈希链消息完整性与 busy 租约防并发,
|
||||
失败不落库(无孤儿/重复消息)。
|
||||
2. **企微/钉钉/飞书扫码登录**(迁移 000039 + `internal/identity/social*.go`):
|
||||
`identity_providers` 扩展三种 kind;管理端「账号与权限 → 扫码登录」配置;
|
||||
登录页自动展示;`start/callback` 复用 SSO 路由按 kind 分发。安全设计:
|
||||
- state 为 128-bit 一次性令牌(Redis one-time,5 分钟),回调原子消费;
|
||||
- 平台 AppSecret 用 IdP 专用 KEK 加密存储,列表/视图只回 `secret_configured`;
|
||||
- `provider_uid` 全局唯一 → 平台账号不可同时绑定多个本系统账号;
|
||||
- 未绑定且未开自动开户时重定向回登录页提示,不静默开户;
|
||||
- 平台端点全部为固定公网 URL,复用 public-only 拨号客户端(无新增出站面)。
|
||||
3. **个人安全策略**(迁移 000040/000041 + 会话索引):
|
||||
- 「账号安全」页:登录设备列表(IP/UA/时间,索引只存令牌 SHA-256 摘要,
|
||||
惰性清理过期项)、吊销任意非当前会话、扫码绑定/解绑;
|
||||
- 新设备登录提醒:登录成功发布 `security.login_detected` outbox 事件,
|
||||
notification worker 依据 `portal_security_prefs.login_notify`(默认开)
|
||||
落站内信(新增 security 类别),可单独关闭;
|
||||
- 当前会话吊销返回 400(`ErrRevokeCurrentSession`)。
|
||||
4. 修复:`request.patch` → `put`(前端无 patch 方法);社交 provider 更新
|
||||
SQL `$14`→`$13` 参数越界;update 返回视图未回读凭据导致 secret_configured
|
||||
显示失真;`/api/v1/portal/chat/` 与 `/api/v1/admin/social-providers` 挂载缺失;
|
||||
start/callback 用 provider code 而非 kind 查询。
|
||||
|
||||
@@ -174,3 +174,20 @@ MinIO 对象存储与管理端/个人文件仓库;pgvector + Ollama(bge-m3)
|
||||
- **旧 Python 网关**:`llm-gateway.service`(systemd)监听 8080 已 `systemctl disable`,不再开机抢占。
|
||||
- **本机无 go 工具链**:编译/测试用 `docker run --rm -v $PWD:/src -w /src -e GOCACHE=/tmp/gocache golang:1.26.5-alpine sh -c 'go build ./...'`;集成测试加 `--network deploy_default` + `WORKBENCH_TEST_DATABASE_URL=postgres://gateway:gateway@postgres:5432/gateway?sslmode=disable`。
|
||||
- **上线门禁未过**:旧库脱敏快照迁移、影子观察、容量验收、切换/回退演练需真实生产数据与授权后方可执行。
|
||||
|
||||
---
|
||||
|
||||
## 八、0.11.2 完成情况(2026-08-13 第三轮完善)
|
||||
|
||||
| 功能 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| 通用聊天 UI | ✅ | 门户「通用聊天」:选模型直接对话、会话管理、审批通过后自动开通用户级 Key |
|
||||
| 企微/钉钉/飞书扫码登录 | ✅ | 身份源配置 + 登录页扫码 + 账号安全绑定/解绑 + 自动开户开关 |
|
||||
| 个人安全策略 | ✅ | 登录设备管理/吊销、新设备登录提醒开关、登录通知站内信(security 类别) |
|
||||
|
||||
剩余依赖外部条件项(已有代码骨架,需真实企业凭据/数据后方可端到端验收):
|
||||
|
||||
- 扫码登录与渠道(企微/钉钉/飞书)真实平台联调:协议实现已单测覆盖(mock 平台端点),
|
||||
需企业开放平台应用凭据完成冒烟。
|
||||
- 多租户数据隔离重构:当前以部门(tenant_id)为租户维度,跨租户物理隔离(独立 schema/库)
|
||||
需明确部署形态后实施。
|
||||
|
||||
@@ -51,10 +51,15 @@ func NewHTTPHandler(service *Service) *HTTPHandler {
|
||||
handler.mux.HandleFunc("GET /api/v1/admin/menus", handler.menus(KindAdmin))
|
||||
handler.mux.HandleFunc("POST /api/v1/portal/login", handler.login(KindPortal))
|
||||
handler.registerTOTP(KindPortal, "/api/v1/portal")
|
||||
handler.mux.HandleFunc("GET /api/v1/portal/sessions", handler.listSessions)
|
||||
handler.mux.HandleFunc("POST /api/v1/portal/sessions/{id}/revoke", handler.revokeSession)
|
||||
handler.mux.HandleFunc("GET /api/v1/portal/security/prefs", handler.securityPrefs)
|
||||
handler.mux.HandleFunc("PUT /api/v1/portal/security/prefs", handler.setSecurityPrefs)
|
||||
handler.mux.HandleFunc("GET /api/v1/portal/me", handler.whoami(KindPortal))
|
||||
handler.mux.HandleFunc("POST /api/v1/portal/logout", handler.logout)
|
||||
handler.mux.HandleFunc("GET /api/v1/portal/menus", handler.menus(KindPortal))
|
||||
handler.registerOIDC()
|
||||
handler.registerSocial()
|
||||
return handler
|
||||
}
|
||||
|
||||
@@ -136,7 +141,7 @@ func (h *HTTPHandler) login(kind Kind) http.HandlerFunc {
|
||||
apiresponse.Error(writer, http.StatusBadRequest, "账号或口令格式无效")
|
||||
return
|
||||
}
|
||||
result, err := h.service.Login(request.Context(), kind, login, input.Password)
|
||||
result, err := h.service.LoginWithMeta(request.Context(), kind, login, input.Password, SessionMeta{IP: h.service.ClientIP(request), UserAgent: request.UserAgent()})
|
||||
if err != nil {
|
||||
// 登录失败审计(429 限流在 AllowLogin 阶段已拦截,此处都是真实失败)。
|
||||
_ = h.service.RecordLoginLog(request.Context(), kind, login, false, h.service.ClientIP(request), request.UserAgent(), loginFailureReason(err))
|
||||
@@ -174,6 +179,66 @@ func loginFailureReason(err error) string {
|
||||
}
|
||||
}
|
||||
|
||||
// listSessions 我的登录设备列表(当前会话标记 current)。
|
||||
func (h *HTTPHandler) listSessions(w http.ResponseWriter, r *http.Request) {
|
||||
account, ok := h.requireAccount(w, r, KindPortal)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.service.ListSessions(r.Context(), KindPortal, account.ID, r.Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
h.writeIdentityError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, items)
|
||||
}
|
||||
|
||||
// revokeSession 吊销指定登录设备(不允许吊销当前会话)。
|
||||
func (h *HTTPHandler) revokeSession(w http.ResponseWriter, r *http.Request) {
|
||||
account, ok := h.requireAccount(w, r, KindPortal)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.RevokeSession(r.Context(), KindPortal, account.ID, r.PathValue("id"), r.Header.Get("Authorization")); err != nil {
|
||||
h.writeIdentityError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"revoked": true})
|
||||
}
|
||||
|
||||
// securityPrefs 我的安全偏好。
|
||||
func (h *HTTPHandler) securityPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
account, ok := h.requireAccount(w, r, KindPortal)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
loginNotify, err := h.service.SecurityPrefs(r.Context(), account.ID)
|
||||
if err != nil {
|
||||
h.writeIdentityError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"login_notify": loginNotify})
|
||||
}
|
||||
|
||||
// setSecurityPrefs 更新安全偏好。
|
||||
func (h *HTTPHandler) setSecurityPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
account, ok := h.requireAccount(w, r, KindPortal)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
LoginNotify bool `json:"login_notify"`
|
||||
}
|
||||
if !decodeJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
if err := h.service.SetSecurityPrefs(r.Context(), account.ID, input.LoginNotify); err != nil {
|
||||
h.writeIdentityError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"login_notify": input.LoginNotify})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) registerTOTP(kind Kind, prefix string) {
|
||||
h.mux.HandleFunc("POST "+prefix+"/login/totp", h.completeTOTPLogin(kind))
|
||||
h.mux.HandleFunc("GET "+prefix+"/totp/status", h.totpStatus(kind))
|
||||
@@ -195,7 +260,7 @@ func (h *HTTPHandler) completeTOTPLogin(kind Kind) http.HandlerFunc {
|
||||
apiresponse.Error(writer, http.StatusBadRequest, "请输入动态验证码或备用码")
|
||||
return
|
||||
}
|
||||
result, err := h.service.CompleteTOTPLogin(request.Context(), kind, input.TempToken, input.Code, input.BackupCode)
|
||||
result, err := h.service.CompleteTOTPLoginWithMeta(request.Context(), kind, input.TempToken, input.Code, input.BackupCode, SessionMeta{IP: h.service.ClientIP(request), UserAgent: request.UserAgent()})
|
||||
if err != nil {
|
||||
_ = h.service.RecordLoginLog(request.Context(), kind, "", false, h.service.ClientIP(request), request.UserAgent(), loginFailureReason(err))
|
||||
h.writeIdentityError(writer, err)
|
||||
@@ -374,6 +439,8 @@ func (h *HTTPHandler) writeIdentityError(writer http.ResponseWriter, err error)
|
||||
apiresponse.Error(writer, http.StatusConflict, "两步验证已经启用")
|
||||
case errors.Is(err, ErrTOTPNotEnabled), errors.Is(err, ErrTOTPSetupRequired):
|
||||
apiresponse.Error(writer, http.StatusConflict, "两步验证尚未完成配置")
|
||||
case errors.Is(err, ErrRevokeCurrentSession):
|
||||
apiresponse.Error(writer, http.StatusBadRequest, "不能吊销当前登录的会话")
|
||||
case errors.Is(err, cryptox.ErrKeyUnavailable):
|
||||
apiresponse.Error(writer, http.StatusServiceUnavailable, "两步验证加密密钥不可用")
|
||||
case errors.Is(err, ErrUnavailable):
|
||||
@@ -506,7 +573,8 @@ func adminMenus(account Account) []map[string]any {
|
||||
func portalMenus() []map[string]any {
|
||||
return []map[string]any{
|
||||
{"name": "Portal", "path": "/portal", "component": "/index/index", "meta": map[string]any{"title": "AI 工作台", "icon": "ri:sparkling-line"}, "children": []map[string]any{
|
||||
{"name": "PortalCatalog", "path": "catalog", "component": "/portal/catalog", "meta": map[string]any{"title": "资产目录", "fixedTab": true}},
|
||||
{"name": "PortalChat", "path": "chat", "component": "/portal/chat", "meta": map[string]any{"title": "通用聊天", "fixedTab": true}},
|
||||
{"name": "PortalCatalog", "path": "catalog", "component": "/portal/catalog", "meta": map[string]any{"title": "资产目录"}},
|
||||
{"name": "PortalMarketplace", "path": "marketplace", "component": "/portal/marketplace", "meta": map[string]any{"title": "资源市场"}},
|
||||
{"name": "PortalPrompts", "path": "prompts", "component": "/portal/prompts", "meta": map[string]any{"title": "Prompt 广场"}},
|
||||
{"name": "PortalUsage", "path": "usage", "component": "/portal/usage", "meta": map[string]any{"title": "我的用量"}},
|
||||
@@ -517,6 +585,7 @@ func portalMenus() []map[string]any {
|
||||
{"name": "PortalMemories", "path": "memories", "component": "/portal/memories", "meta": map[string]any{"title": "记忆管理"}},
|
||||
{"name": "PortalEnvVars", "path": "env-vars", "component": "/portal/env-vars", "meta": map[string]any{"title": "环境变量"}},
|
||||
{"name": "PortalLoginLogs", "path": "login-logs", "component": "/portal/login-logs", "meta": map[string]any{"title": "登录记录"}},
|
||||
{"name": "PortalSecurity", "path": "security", "component": "/portal/security", "meta": map[string]any{"title": "账号安全"}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ func NewManagementHTTPHandler(service *Service) *ManagementHTTPHandler {
|
||||
h.mux.HandleFunc("POST /api/v1/admin/departments", h.createDepartment)
|
||||
h.mux.HandleFunc("PUT /api/v1/admin/departments/{department_id}", h.updateDepartment)
|
||||
h.registerOIDC()
|
||||
h.registerSocialAdmin()
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
@@ -84,12 +84,27 @@ func (h *ManagementHTTPHandler) registerOIDC() {
|
||||
func (h *HTTPHandler) registerOIDC() {
|
||||
h.mux.HandleFunc("GET /api/v1/portal/sso/providers", h.listPublicOIDCProviders)
|
||||
h.mux.HandleFunc("GET /api/v1/portal/sso/{provider_code}/start", h.startSSO)
|
||||
h.mux.HandleFunc("GET /api/v1/portal/sso/{provider_code}/callback", h.callbackOIDC)
|
||||
h.mux.HandleFunc("GET /api/v1/portal/sso/{provider_code}/callback", h.callbackSSO)
|
||||
h.mux.HandleFunc("POST /api/v1/portal/sso/{provider_code}/callback", h.callbackSAML)
|
||||
h.mux.HandleFunc("GET /api/v1/portal/sso/{provider_code}/metadata", h.samlMetadata)
|
||||
h.mux.HandleFunc("POST /api/v1/portal/sso/exchange", h.exchangeOIDC)
|
||||
}
|
||||
|
||||
// callbackSSO 按身份源 kind 分发 GET 回调:OIDC 走标准 code 交换,扫码登录
|
||||
// 走企微/钉钉/飞书协议。
|
||||
func (h *HTTPHandler) callbackSSO(w http.ResponseWriter, r *http.Request) {
|
||||
kind, err := h.service.repository.GetIdentityProviderKind(r.Context(), r.PathValue("provider_code"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if socialKindSupported(kind) {
|
||||
h.callbackSocial(w, r)
|
||||
return
|
||||
}
|
||||
h.callbackOIDC(w, r)
|
||||
}
|
||||
|
||||
func (h *ManagementHTTPHandler) listOIDCProviders(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePermission(w, r); !ok {
|
||||
return
|
||||
@@ -349,11 +364,12 @@ func (h *HTTPHandler) callbackOIDC(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeIdentityError(w, ErrAccountDisabled)
|
||||
return
|
||||
}
|
||||
token, err := h.service.sessions.Create(r.Context(), principalFor(account))
|
||||
token, err := h.service.sessions.CreateWithMeta(r.Context(), principalFor(account), h.service.ClientIP(r), r.UserAgent())
|
||||
if err != nil {
|
||||
h.writeIdentityError(w, err)
|
||||
return
|
||||
}
|
||||
h.service.NotifyLogin(r.Context(), account.ID, SessionMeta{IP: h.service.ClientIP(r), UserAgent: r.UserAgent()})
|
||||
exchange, err := h.service.sessions.StoreOneTime(r.Context(), "oidc-exchange", oidcExchange{Token: token}, time.Minute)
|
||||
if err != nil {
|
||||
h.writeIdentityError(w, err)
|
||||
|
||||
@@ -222,6 +222,10 @@ func (h *HTTPHandler) startSSO(w http.ResponseWriter, r *http.Request) {
|
||||
h.startSAML(w, r)
|
||||
return
|
||||
}
|
||||
if socialKindSupported(kind) {
|
||||
h.startSocial(w, r)
|
||||
return
|
||||
}
|
||||
h.startOIDC(w, r)
|
||||
}
|
||||
|
||||
@@ -308,11 +312,12 @@ func (h *HTTPHandler) callbackSAML(w http.ResponseWriter, r *http.Request) {
|
||||
h.writeIdentityError(w, ErrAccountDisabled)
|
||||
return
|
||||
}
|
||||
token, err := h.service.sessions.Create(r.Context(), principalFor(account))
|
||||
token, err := h.service.sessions.CreateWithMeta(r.Context(), principalFor(account), h.service.ClientIP(r), r.UserAgent())
|
||||
if err != nil {
|
||||
h.writeIdentityError(w, err)
|
||||
return
|
||||
}
|
||||
h.service.NotifyLogin(r.Context(), account.ID, SessionMeta{IP: h.service.ClientIP(r), UserAgent: r.UserAgent()})
|
||||
exchange, err := h.service.sessions.StoreOneTime(r.Context(), "oidc-exchange", oidcExchange{Token: token}, time.Minute)
|
||||
if err != nil {
|
||||
h.writeIdentityError(w, err)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
platformid "aigateway.local/core/internal/platform/id"
|
||||
)
|
||||
|
||||
// InsertOutboxEvent 发布一条 outbox 事件(供通知 worker 消费)。
|
||||
func (r *Repository) InsertOutboxEvent(ctx context.Context, eventType, aggregateType, aggregateID string, payload []byte) error {
|
||||
if r.pool == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
eventID, err := platformid.NewUUID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.pool.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,$3,$4,$5)`, eventID, eventType, aggregateType, aggregateID, payload)
|
||||
return mapRepositoryError(err)
|
||||
}
|
||||
|
||||
// SecurityPrefs 返回登录通知偏好;未配置时默认开启。
|
||||
func (r *Repository) SecurityPrefs(ctx context.Context, portalUserID string) (bool, error) {
|
||||
var loginNotify bool
|
||||
err := r.pool.QueryRow(ctx, `SELECT COALESCE((SELECT login_notify FROM gateway.portal_security_prefs WHERE portal_user_id=$1),true)`, portalUserID).Scan(&loginNotify)
|
||||
return loginNotify, mapRepositoryError(err)
|
||||
}
|
||||
|
||||
// SetSecurityPrefs 更新登录通知偏好。
|
||||
func (r *Repository) SetSecurityPrefs(ctx context.Context, portalUserID string, loginNotify bool) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO gateway.portal_security_prefs(portal_user_id,login_notify) VALUES($1,$2)
|
||||
ON CONFLICT(portal_user_id) DO UPDATE SET login_notify=$2,updated_at=clock_timestamp()`, portalUserID, loginNotify)
|
||||
return mapRepositoryError(err)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -96,7 +97,17 @@ func (s *Service) ClientIP(r *http.Request) string {
|
||||
return s.limiter.ClientIP(r)
|
||||
}
|
||||
|
||||
// SessionMeta 携带登录环境信息(IP/UA),用于设备管理与登录提醒。
|
||||
type SessionMeta struct {
|
||||
IP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, kind Kind, login, password string) (LoginResult, error) {
|
||||
return s.LoginWithMeta(ctx, kind, login, password, SessionMeta{})
|
||||
}
|
||||
|
||||
func (s *Service) LoginWithMeta(ctx context.Context, kind Kind, login, password string, meta SessionMeta) (LoginResult, error) {
|
||||
account, err := s.findByLogin(ctx, kind, login)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
_ = s.hasher.Verify(password, dummyPasswordHash)
|
||||
@@ -139,7 +150,7 @@ func (s *Service) Login(ctx context.Context, kind Kind, login, password string)
|
||||
upgradedHash = &hash
|
||||
}
|
||||
principal := principalFor(account)
|
||||
token, err := s.sessions.Create(ctx, principal)
|
||||
token, err := s.sessions.CreateWithMeta(ctx, principal, meta.IP, meta.UserAgent)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
@@ -147,10 +158,17 @@ func (s *Service) Login(ctx context.Context, kind Kind, login, password string)
|
||||
_ = s.sessions.Delete(ctx, "Bearer "+token)
|
||||
return LoginResult{}, err
|
||||
}
|
||||
if kind == KindPortal {
|
||||
s.NotifyLogin(ctx, account.ID, meta)
|
||||
}
|
||||
return LoginResult{Token: token, Account: account}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CompleteTOTPLogin(ctx context.Context, kind Kind, tempToken, code, backupCode string) (LoginResult, error) {
|
||||
return s.CompleteTOTPLoginWithMeta(ctx, kind, tempToken, code, backupCode, SessionMeta{})
|
||||
}
|
||||
|
||||
func (s *Service) CompleteTOTPLoginWithMeta(ctx context.Context, kind Kind, tempToken, code, backupCode string, meta SessionMeta) (LoginResult, error) {
|
||||
// 先只读取(不消费)挑战令牌:验证码输错时令牌保留,用户可用同一
|
||||
// 令牌重试,而不是每个笔误都强制重新走完整登录。
|
||||
principal, err := s.sessions.AuthenticatePending(ctx, tempToken, kind)
|
||||
@@ -189,7 +207,7 @@ func (s *Service) CompleteTOTPLogin(ctx context.Context, kind Kind, tempToken, c
|
||||
if _, err := s.sessions.ConsumePending(ctx, tempToken, kind); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
token, err := s.sessions.Create(ctx, principalFor(account))
|
||||
token, err := s.sessions.CreateWithMeta(ctx, principalFor(account), meta.IP, meta.UserAgent)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
@@ -198,9 +216,42 @@ func (s *Service) CompleteTOTPLogin(ctx context.Context, kind Kind, tempToken, c
|
||||
return LoginResult{}, err
|
||||
}
|
||||
_ = s.sessions.DeleteToken(ctx, tempToken)
|
||||
if kind == KindPortal {
|
||||
s.NotifyLogin(ctx, account.ID, meta)
|
||||
}
|
||||
return LoginResult{Token: token, Account: account}, nil
|
||||
}
|
||||
|
||||
// NotifyLogin 发布"新设备登录"事件(尽力而为,失败不影响登录)。站内信是否
|
||||
// 落盘由通知 worker 按用户的安全偏好决定。
|
||||
func (s *Service) NotifyLogin(ctx context.Context, portalUserID string, meta SessionMeta) {
|
||||
if s.repository == nil || portalUserID == "" {
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"portal_user_id": portalUserID, "ip": meta.IP, "user_agent": meta.UserAgent})
|
||||
_ = s.repository.InsertOutboxEvent(ctx, "security.login_detected", "identity", portalUserID, payload)
|
||||
}
|
||||
|
||||
// ListSessions 返回账号的有效会话(我的登录设备)。
|
||||
func (s *Service) ListSessions(ctx context.Context, kind Kind, subjectID, authorization string) ([]SessionView, error) {
|
||||
return s.sessions.ListSessions(ctx, kind, subjectID, authorization)
|
||||
}
|
||||
|
||||
// RevokeSession 吊销指定会话(当前会话除外)。
|
||||
func (s *Service) RevokeSession(ctx context.Context, kind Kind, subjectID, sessionID, authorization string) error {
|
||||
return s.sessions.RevokeSession(ctx, kind, subjectID, sessionID, authorization)
|
||||
}
|
||||
|
||||
// SecurityPrefs 返回门户账号的安全偏好(登录通知开关,默认开启)。
|
||||
func (s *Service) SecurityPrefs(ctx context.Context, portalUserID string) (bool, error) {
|
||||
return s.repository.SecurityPrefs(ctx, portalUserID)
|
||||
}
|
||||
|
||||
// SetSecurityPrefs 更新门户账号的安全偏好。
|
||||
func (s *Service) SetSecurityPrefs(ctx context.Context, portalUserID string, loginNotify bool) error {
|
||||
return s.repository.SetSecurityPrefs(ctx, portalUserID, loginNotify)
|
||||
}
|
||||
|
||||
func (s *Service) SetupTOTP(ctx context.Context, account Account, password string) (TOTPSetupResult, error) {
|
||||
if account.TOTPEnabled {
|
||||
return TOTPSetupResult{}, ErrTOTPAlreadyEnabled
|
||||
|
||||
@@ -25,6 +25,9 @@ type Principal struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Purpose string `json:"purpose"`
|
||||
IssuedAt int64 `json:"issued_at"`
|
||||
// IP 与 UserAgent 记录签发时的登录环境,供"我的登录设备"展示与登录提醒。
|
||||
IP string `json:"ip,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
// AuthVersion 是签发会话时账号的凭据版本;凭据变更(改密/2FA 变更)会
|
||||
// 递增该版本,旧版本会话在 Authenticate 时被拒绝,被盗会话无法在
|
||||
// 凭据轮换后继续存活。
|
||||
@@ -76,6 +79,14 @@ func (s *SessionStore) Create(ctx context.Context, principal Principal) (string,
|
||||
return s.create(ctx, principal, s.ttl)
|
||||
}
|
||||
|
||||
// CreateWithMeta 签发会话并记录登录环境(IP/UA),供设备管理与登录提醒使用。
|
||||
func (s *SessionStore) CreateWithMeta(ctx context.Context, principal Principal, ip, userAgent string) (string, error) {
|
||||
principal.Purpose = "session"
|
||||
principal.IP = truncate(strings.TrimSpace(ip), 64)
|
||||
principal.UserAgent = truncate(strings.TrimSpace(userAgent), 256)
|
||||
return s.create(ctx, principal, s.ttl)
|
||||
}
|
||||
|
||||
func (s *SessionStore) CreatePending(ctx context.Context, principal Principal, ttl time.Duration) (string, error) {
|
||||
principal.Purpose = "totp_pending"
|
||||
return s.create(ctx, principal, ttl)
|
||||
@@ -99,6 +110,11 @@ func (s *SessionStore) create(ctx context.Context, principal Principal, ttl time
|
||||
if err := s.client.Set(ctx, sessionKey(token), payload, ttl).Err(); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
// 维护账号的会话索引(仅正式会话,不含 TOTP 挑战令牌),供
|
||||
// "我的登录设备"列出与吊销。索引不做 TTL 管理,列出时惰性清理过期项。
|
||||
if principal.Purpose == "session" {
|
||||
_ = s.client.SAdd(ctx, sessionIndexKey(principal.Kind, principal.SubjectID), sessionHex(token)).Err()
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
@@ -198,6 +214,107 @@ func (s *SessionStore) Delete(ctx context.Context, authorization string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SessionView 是"我的登录设备"视图。
|
||||
type SessionView struct {
|
||||
ID string `json:"id"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
IssuedAt int64 `json:"issued_at"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// ListSessions 列出账号的全部有效会话并惰性清理已过期项。
|
||||
// currentToken 为当前请求的会话,标记 current=true。
|
||||
func (s *SessionStore) ListSessions(ctx context.Context, kind Kind, subjectID, currentAuthorization string) ([]SessionView, error) {
|
||||
if s.client == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
hexes, err := s.client.SMembers(ctx, sessionIndexKey(kind, subjectID)).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
items := []SessionView{}
|
||||
for _, hex := range hexes {
|
||||
payload, err := s.client.Get(ctx, sessionKeyFromHex(hex)).Bytes()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
_ = s.client.SRem(ctx, sessionIndexKey(kind, subjectID), hex).Err()
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
var principal Principal
|
||||
if json.Unmarshal(payload, &principal) != nil || principal.Kind != kind || principal.SubjectID != subjectID || principal.Purpose != "session" {
|
||||
_ = s.client.SRem(ctx, sessionIndexKey(kind, subjectID), hex).Err()
|
||||
continue
|
||||
}
|
||||
items = append(items, SessionView{ID: hex, IP: principal.IP, UserAgent: principal.UserAgent, IssuedAt: principal.IssuedAt,
|
||||
Current: s.sessionHexMatches(currentAuthorization, hex)})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ErrRevokeCurrentSession 表示试图吊销当前登录的会话。
|
||||
var ErrRevokeCurrentSession = errors.New("不能吊销当前登录的会话")
|
||||
|
||||
// RevokeSession 吊销指定会话(hex ID);当前会话不得吊销。
|
||||
func (s *SessionStore) RevokeSession(ctx context.Context, kind Kind, subjectID, sessionID, currentAuthorization string) error {
|
||||
if s.client == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if len(sessionID) != 64 || !isHex(sessionID) {
|
||||
return errors.New("会话标识无效")
|
||||
}
|
||||
if s.sessionHexMatches(currentAuthorization, sessionID) {
|
||||
return ErrRevokeCurrentSession
|
||||
}
|
||||
removed, err := s.client.SRem(ctx, sessionIndexKey(kind, subjectID), sessionID).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
if removed == 0 {
|
||||
return ErrInvalidSession
|
||||
}
|
||||
if err := s.client.Del(ctx, sessionKeyFromHex(sessionID)).Err(); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SessionStore) sessionHexMatches(authorization, hex string) bool {
|
||||
token, ok := bearerToken(authorization)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return sessionHex(token) == hex
|
||||
}
|
||||
|
||||
func sessionIndexKey(kind Kind, subjectID string) string {
|
||||
return "gateway:session-index:" + string(kind) + ":" + subjectID
|
||||
}
|
||||
|
||||
func sessionHex(token string) string {
|
||||
digest := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func sessionKeyFromHex(value string) string {
|
||||
return "gateway:session:v1:" + value
|
||||
}
|
||||
|
||||
func isHex(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *SessionStore) StoreOneTime(ctx context.Context, namespace string, value any, ttl time.Duration) (string, error) {
|
||||
if s.client == nil {
|
||||
return "", ErrUnavailable
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const socialStateTTL = 5 * time.Minute
|
||||
|
||||
// ErrSocialUnbound 表示平台账号未绑定本系统账号(且未开启自动开通)。
|
||||
var ErrSocialUnbound = errors.New("该企业账号尚未绑定本系统账号,请先用账号密码登录后在「账号安全」中绑定")
|
||||
|
||||
type socialIdentity struct {
|
||||
UID string // 平台稳定唯一标识(userid/unionId/union_id)
|
||||
Name string
|
||||
}
|
||||
|
||||
type socialChallenge struct {
|
||||
ProviderID string `json:"provider_id"`
|
||||
Purpose string `json:"purpose"` // login | bind
|
||||
BindUserID string `json:"bind_user_id,omitempty"`
|
||||
}
|
||||
|
||||
type SocialLoginResult struct {
|
||||
Purpose string // login | bind
|
||||
SSOCode string // login 成功后的一次性交换码(前端换取会话)
|
||||
BindOK bool // bind 流程是否成功
|
||||
BindConflict bool // bind 流程:该平台账号已被他人绑定
|
||||
}
|
||||
|
||||
// socialKindSupported 校验扫码登录平台 kind。
|
||||
func socialKindSupported(kind string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(kind)) {
|
||||
case "wecom", "dingtalk", "feishu":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SocialLoginURL 构造跳转企业身份源的登录/绑定 URL。
|
||||
func (s *Service) SocialLoginURL(ctx context.Context, kind, purpose, bindUserID string) (string, error) {
|
||||
if !socialKindSupported(kind) {
|
||||
return "", errors.New("不支持的扫码登录平台")
|
||||
}
|
||||
provider, err := s.repository.GetSocialProviderByKind(ctx, kind)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !provider.Enabled {
|
||||
return "", errors.New("该登录方式未启用")
|
||||
}
|
||||
if provider.ClientID == "" || provider.RedirectURI == "" || provider.PortalReturnURL == "" {
|
||||
return "", errors.New("身份源配置不完整")
|
||||
}
|
||||
if provider.Kind == "wecom" && provider.AgentID == "" {
|
||||
return "", errors.New("企业微信身份源缺少 AgentID")
|
||||
}
|
||||
secret, err := s.socialSecret(provider)
|
||||
if err != nil || secret == "" {
|
||||
return "", errors.New("身份源密钥未配置")
|
||||
}
|
||||
state, err := s.sessions.StoreOneTime(ctx, "social-state", socialChallenge{ProviderID: provider.ID, Purpose: purpose, BindUserID: bindUserID}, socialStateTTL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
redirect, _ := url.Parse(provider.RedirectURI)
|
||||
query := redirect.Query()
|
||||
query.Set("state", state)
|
||||
redirect.RawQuery = query.Encode()
|
||||
switch provider.Kind {
|
||||
case "wecom":
|
||||
target, _ := url.Parse("https://open.work.weixin.qq.com/wwopen/sso/qrConnect")
|
||||
q := target.Query()
|
||||
q.Set("appid", provider.ClientID)
|
||||
q.Set("agentid", provider.AgentID)
|
||||
q.Set("redirect_uri", redirect.String())
|
||||
q.Set("state", state)
|
||||
target.RawQuery = q.Encode()
|
||||
return target.String(), nil
|
||||
case "dingtalk":
|
||||
target, _ := url.Parse("https://login.dingtalk.com/oauth2/auth")
|
||||
q := target.Query()
|
||||
q.Set("redirect_uri", redirect.String())
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", provider.ClientID)
|
||||
q.Set("scope", "openid")
|
||||
q.Set("state", state)
|
||||
q.Set("prompt", "consent")
|
||||
target.RawQuery = q.Encode()
|
||||
return target.String(), nil
|
||||
case "feishu":
|
||||
target, _ := url.Parse("https://open.feishu.cn/open-apis/authen/v1/authorize")
|
||||
q := target.Query()
|
||||
q.Set("app_id", provider.ClientID)
|
||||
q.Set("redirect_uri", redirect.String())
|
||||
q.Set("state", state)
|
||||
target.RawQuery = q.Encode()
|
||||
return target.String(), nil
|
||||
}
|
||||
return "", errors.New("不支持的扫码登录平台")
|
||||
}
|
||||
|
||||
// CompleteSocialLogin 处理平台回调:校验 state、换取平台身份、按目的登录或绑定。
|
||||
// meta 携带回调请求的登录环境(IP/UA)。
|
||||
func (s *Service) CompleteSocialLogin(ctx context.Context, kind, state, code string, meta SessionMeta) (SocialLoginResult, error) {
|
||||
kind = strings.ToLower(strings.TrimSpace(kind))
|
||||
if !socialKindSupported(kind) || strings.TrimSpace(state) == "" || strings.TrimSpace(code) == "" {
|
||||
return SocialLoginResult{}, errors.New("扫码登录回调参数无效")
|
||||
}
|
||||
var challenge socialChallenge
|
||||
if err := s.sessions.ConsumeOneTime(ctx, "social-state", state, &challenge); err != nil {
|
||||
return SocialLoginResult{}, errors.New("登录状态无效或已过期,请重新扫码")
|
||||
}
|
||||
provider, err := s.repository.GetSocialProviderByKind(ctx, kind)
|
||||
if err != nil || provider.ID != challenge.ProviderID || !provider.Enabled {
|
||||
return SocialLoginResult{}, errors.New("登录身份源无效或已停用")
|
||||
}
|
||||
identity, err := s.exchangeSocial(ctx, provider, code)
|
||||
if err != nil {
|
||||
return SocialLoginResult{}, err
|
||||
}
|
||||
if challenge.Purpose == "bind" {
|
||||
return s.completeSocialBind(ctx, provider, challenge.BindUserID, identity)
|
||||
}
|
||||
if challenge.Purpose != "login" {
|
||||
return SocialLoginResult{}, errors.New("登录状态无效")
|
||||
}
|
||||
accountID, err := s.repository.FindProviderBinding(ctx, provider.Kind, identity.UID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
if !provider.AutoProvision {
|
||||
return SocialLoginResult{}, ErrSocialUnbound
|
||||
}
|
||||
account, provisionErr := s.repository.resolveExternalAccount(ctx, externalProvider{
|
||||
ID: provider.ID, Code: provider.Code, AuthSource: provider.Kind, AutoProvision: true, DefaultDepartmentID: provider.DefaultDepartmentID,
|
||||
}, externalClaims{Subject: identity.UID, Name: identity.Name})
|
||||
if provisionErr != nil {
|
||||
return SocialLoginResult{}, provisionErr
|
||||
}
|
||||
accountID = account.ID
|
||||
} else if err != nil {
|
||||
return SocialLoginResult{}, err
|
||||
}
|
||||
account, err := s.findByID(ctx, KindPortal, accountID)
|
||||
if err != nil {
|
||||
return SocialLoginResult{}, err
|
||||
}
|
||||
if !account.Active {
|
||||
return SocialLoginResult{}, ErrAccountDisabled
|
||||
}
|
||||
token, err := s.sessions.CreateWithMeta(ctx, principalFor(account), meta.IP, meta.UserAgent)
|
||||
if err != nil {
|
||||
return SocialLoginResult{}, err
|
||||
}
|
||||
s.NotifyLogin(ctx, account.ID, meta)
|
||||
exchange, err := s.sessions.StoreOneTime(ctx, "oidc-exchange", oidcExchange{Token: token}, time.Minute)
|
||||
if err != nil {
|
||||
return SocialLoginResult{}, err
|
||||
}
|
||||
return SocialLoginResult{Purpose: "login", SSOCode: exchange}, nil
|
||||
}
|
||||
|
||||
// completeSocialBind 处理绑定回调:同一平台账号只能绑到一个本系统账号。
|
||||
func (s *Service) completeSocialBind(ctx context.Context, provider SocialProvider, bindUserID string, identity socialIdentity) (SocialLoginResult, error) {
|
||||
if bindUserID == "" {
|
||||
return SocialLoginResult{}, errors.New("绑定状态无效")
|
||||
}
|
||||
account, err := s.findByID(ctx, KindPortal, bindUserID)
|
||||
if err != nil || !account.Active {
|
||||
return SocialLoginResult{}, errors.New("绑定账号不存在或已停用")
|
||||
}
|
||||
if existing, err := s.repository.FindProviderBinding(ctx, provider.Kind, identity.UID); err == nil {
|
||||
if existing == bindUserID {
|
||||
return SocialLoginResult{Purpose: "bind", BindOK: true}, nil
|
||||
}
|
||||
return SocialLoginResult{Purpose: "bind", BindConflict: true}, nil
|
||||
}
|
||||
if err := s.repository.BindProvider(ctx, bindUserID, provider.Kind, identity.UID); err != nil {
|
||||
if strings.Contains(err.Error(), "已被其他") {
|
||||
return SocialLoginResult{Purpose: "bind", BindConflict: true}, nil
|
||||
}
|
||||
return SocialLoginResult{}, err
|
||||
}
|
||||
return SocialLoginResult{Purpose: "bind", BindOK: true}, nil
|
||||
}
|
||||
|
||||
// UnbindProvider 解除扫码绑定(仅本人)。
|
||||
func (s *Service) UnbindProvider(ctx context.Context, portalUserID, kind string) error {
|
||||
return s.repository.UnbindProvider(ctx, portalUserID, kind)
|
||||
}
|
||||
|
||||
// ProviderBindings 返回账号的扫码绑定列表。
|
||||
func (s *Service) ProviderBindings(ctx context.Context, portalUserID string) ([]ProviderBinding, error) {
|
||||
return s.repository.ListProviderBindings(ctx, portalUserID)
|
||||
}
|
||||
|
||||
// socialSecret 解密平台 AppSecret。
|
||||
func (s *Service) socialSecret(p SocialProvider) (string, error) {
|
||||
if s.idpCipher == nil || len(p.EncryptedCredentials) == 0 {
|
||||
return "", ErrUnavailable
|
||||
}
|
||||
plaintext, err := s.idpCipher.Decrypt(p.EncryptedCredentials, p.CredentialKEKVersion)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var credentials socialCredentials
|
||||
if err := json.Unmarshal(plaintext, &credentials); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return credentials.Secret, nil
|
||||
}
|
||||
|
||||
// exchangeSocial 用回调 code 换取平台身份。全部为固定公网端点,复用公共地址
|
||||
// 白名单拨号的 oidcClient,不引入新的出站面。
|
||||
func (s *Service) exchangeSocial(ctx context.Context, p SocialProvider, code string) (socialIdentity, error) {
|
||||
secret, err := s.socialSecret(p)
|
||||
if err != nil || secret == "" {
|
||||
return socialIdentity{}, errors.New("身份源密钥不可用")
|
||||
}
|
||||
switch p.Kind {
|
||||
case "wecom":
|
||||
return s.exchangeWeCom(ctx, p, secret, code)
|
||||
case "dingtalk":
|
||||
return s.exchangeDingTalk(ctx, p, secret, code)
|
||||
case "feishu":
|
||||
return s.exchangeFeishu(ctx, p, secret, code)
|
||||
}
|
||||
return socialIdentity{}, errors.New("不支持的扫码登录平台")
|
||||
}
|
||||
|
||||
func (s *Service) exchangeWeCom(ctx context.Context, p SocialProvider, secret, code string) (socialIdentity, error) {
|
||||
tokenURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s", url.QueryEscape(p.ClientID), url.QueryEscape(secret))
|
||||
var token struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := s.socialGetJSON(ctx, tokenURL, &token); err != nil || token.ErrCode != 0 || token.AccessToken == "" {
|
||||
return socialIdentity{}, errors.New("企业微信 access_token 获取失败")
|
||||
}
|
||||
var user struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
UserID string `json:"userid"`
|
||||
OpenID string `json:"openid"`
|
||||
}
|
||||
userURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token=%s&code=%s", url.QueryEscape(token.AccessToken), url.QueryEscape(code))
|
||||
if err := s.socialGetJSON(ctx, userURL, &user); err != nil || user.ErrCode != 0 {
|
||||
return socialIdentity{}, errors.New("企业微信用户信息获取失败")
|
||||
}
|
||||
uid := strings.TrimSpace(user.UserID)
|
||||
if uid == "" {
|
||||
uid = strings.TrimSpace(user.OpenID)
|
||||
}
|
||||
if uid == "" {
|
||||
return socialIdentity{}, errors.New("企业微信未返回用户标识")
|
||||
}
|
||||
return socialIdentity{UID: uid, Name: uid}, nil
|
||||
}
|
||||
|
||||
func (s *Service) exchangeDingTalk(ctx context.Context, p SocialProvider, secret, code string) (socialIdentity, error) {
|
||||
payload, _ := json.Marshal(map[string]string{"clientId": p.ClientID, "clientSecret": secret, "code": code, "grantType": "authorization_code"})
|
||||
var token struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.dingtalk.com/v1.0/oauth2/userAccessToken", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return socialIdentity{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := s.oidcHTTPClient().Do(request)
|
||||
if err != nil {
|
||||
return socialIdentity{}, errors.New("钉钉 access_token 获取失败")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<16))
|
||||
if response.StatusCode/100 != 2 || json.Unmarshal(raw, &token) != nil || token.AccessToken == "" {
|
||||
return socialIdentity{}, errors.New("钉钉 access_token 获取失败")
|
||||
}
|
||||
userRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.dingtalk.com/v1.0/contact/users/me", nil)
|
||||
if err != nil {
|
||||
return socialIdentity{}, err
|
||||
}
|
||||
userRequest.Header.Set("x-acs-dingtalk-access-token", token.AccessToken)
|
||||
userResponse, err := s.oidcHTTPClient().Do(userRequest)
|
||||
if err != nil {
|
||||
return socialIdentity{}, errors.New("钉钉用户信息获取失败")
|
||||
}
|
||||
defer userResponse.Body.Close()
|
||||
raw, _ = io.ReadAll(io.LimitReader(userResponse.Body, 1<<16))
|
||||
var user struct {
|
||||
UnionID string `json:"unionId"`
|
||||
OpenID string `json:"openId"`
|
||||
Nick string `json:"nick"`
|
||||
}
|
||||
if userResponse.StatusCode/100 != 2 || json.Unmarshal(raw, &user) != nil {
|
||||
return socialIdentity{}, errors.New("钉钉用户信息获取失败")
|
||||
}
|
||||
uid := strings.TrimSpace(user.UnionID)
|
||||
if uid == "" {
|
||||
uid = strings.TrimSpace(user.OpenID)
|
||||
}
|
||||
if uid == "" {
|
||||
return socialIdentity{}, errors.New("钉钉未返回用户标识")
|
||||
}
|
||||
return socialIdentity{UID: uid, Name: firstNonEmpty(user.Nick, uid)}, nil
|
||||
}
|
||||
|
||||
func (s *Service) exchangeFeishu(ctx context.Context, p SocialProvider, secret, code string) (socialIdentity, error) {
|
||||
payload, _ := json.Marshal(map[string]string{"app_id": p.ClientID, "app_secret": secret, "code": code, "grant_type": "authorization_code"})
|
||||
var token struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://open.feishu.cn/open-apis/authen/v1/oidc/access_token", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return socialIdentity{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := s.oidcHTTPClient().Do(request)
|
||||
if err != nil {
|
||||
return socialIdentity{}, errors.New("飞书 access_token 获取失败")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(response.Body, 1<<16))
|
||||
if response.StatusCode/100 != 2 || json.Unmarshal(raw, &token) != nil || token.Code != 0 || token.Data.AccessToken == "" {
|
||||
return socialIdentity{}, errors.New("飞书 access_token 获取失败")
|
||||
}
|
||||
userRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://open.feishu.cn/open-apis/authen/v1/user_info", nil)
|
||||
if err != nil {
|
||||
return socialIdentity{}, err
|
||||
}
|
||||
userRequest.Header.Set("Authorization", "Bearer "+token.Data.AccessToken)
|
||||
userResponse, err := s.oidcHTTPClient().Do(userRequest)
|
||||
if err != nil {
|
||||
return socialIdentity{}, errors.New("飞书用户信息获取失败")
|
||||
}
|
||||
defer userResponse.Body.Close()
|
||||
raw, _ = io.ReadAll(io.LimitReader(userResponse.Body, 1<<16))
|
||||
var user struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
OpenID string `json:"open_id"`
|
||||
UnionID string `json:"union_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if userResponse.StatusCode/100 != 2 || json.Unmarshal(raw, &user) != nil || user.Code != 0 {
|
||||
return socialIdentity{}, errors.New("飞书用户信息获取失败")
|
||||
}
|
||||
uid := strings.TrimSpace(user.Data.UnionID)
|
||||
if uid == "" {
|
||||
uid = strings.TrimSpace(user.Data.OpenID)
|
||||
}
|
||||
if uid == "" {
|
||||
return socialIdentity{}, errors.New("飞书未返回用户标识")
|
||||
}
|
||||
return socialIdentity{UID: uid, Name: firstNonEmpty(user.Data.Name, uid)}, nil
|
||||
}
|
||||
|
||||
// socialGetJSON 执行 GET 并解码 JSON(限长)。
|
||||
func (s *Service) socialGetJSON(ctx context.Context, endpoint string, target any) error {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := s.oidcHTTPClient().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 errors.New("platform http error")
|
||||
}
|
||||
return json.Unmarshal(raw, target)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"aigateway.local/core/internal/platform/apiresponse"
|
||||
)
|
||||
|
||||
// registerSocialAdmin 注册扫码登录身份源的管理端点。
|
||||
func (h *ManagementHTTPHandler) registerSocialAdmin() {
|
||||
h.mux.HandleFunc("GET /api/v1/admin/social-providers", h.listSocialProviders)
|
||||
h.mux.HandleFunc("POST /api/v1/admin/social-providers", h.createSocialProvider)
|
||||
h.mux.HandleFunc("PUT /api/v1/admin/social-providers/{kind}", h.updateSocialProvider)
|
||||
h.mux.HandleFunc("DELETE /api/v1/admin/social-providers/{kind}", h.deleteSocialProvider)
|
||||
}
|
||||
|
||||
type socialProviderInput struct {
|
||||
Code string `json:"code"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ClientID string `json:"client_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Secret *string `json:"secret"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
PortalReturnURL string `json:"portal_return_url"`
|
||||
AutoProvision bool `json:"auto_provision"`
|
||||
DefaultDepartmentID *string `json:"default_department_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (h *ManagementHTTPHandler) decodeSocialProvider(w http.ResponseWriter, r *http.Request, creating bool) (socialProviderInput, SocialProvider, bool) {
|
||||
var input socialProviderInput
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(&input) != nil {
|
||||
apiresponse.Error(w, 400, "请求格式无效")
|
||||
return input, SocialProvider{}, false
|
||||
}
|
||||
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
input.ClientID = strings.TrimSpace(input.ClientID)
|
||||
input.AgentID = strings.TrimSpace(input.AgentID)
|
||||
if input.Code == "" || input.DisplayName == "" || input.ClientID == "" ||
|
||||
(creating && input.Secret == nil) || (input.Secret != nil && strings.TrimSpace(*input.Secret) == "") {
|
||||
apiresponse.Error(w, 400, "身份源代码、名称、AppID 或 AppSecret 无效")
|
||||
return input, SocialProvider{}, false
|
||||
}
|
||||
redirectURI, err := validateAbsoluteURL(input.RedirectURI)
|
||||
if err != nil || strings.Contains(redirectURI, "#") {
|
||||
apiresponse.Error(w, 400, "回调 URL 无效")
|
||||
return input, SocialProvider{}, false
|
||||
}
|
||||
returnURL, err := validateAbsoluteURL(input.PortalReturnURL)
|
||||
if err != nil {
|
||||
apiresponse.Error(w, 400, "门户返回 URL 无效")
|
||||
return input, SocialProvider{}, false
|
||||
}
|
||||
if input.DefaultDepartmentID != nil && *input.DefaultDepartmentID != "" {
|
||||
department, err := h.service.repository.GetDepartment(r.Context(), *input.DefaultDepartmentID)
|
||||
if err != nil || !department.Active {
|
||||
apiresponse.Error(w, 400, "默认部门不存在或已停用")
|
||||
return input, SocialProvider{}, false
|
||||
}
|
||||
}
|
||||
return input, SocialProvider{Code: input.Code, DisplayName: input.DisplayName, ClientID: input.ClientID, AgentID: input.AgentID,
|
||||
RedirectURI: redirectURI, PortalReturnURL: returnURL, AutoProvision: input.AutoProvision,
|
||||
DefaultDepartmentID: input.DefaultDepartmentID, Enabled: input.Enabled}, true
|
||||
}
|
||||
|
||||
func (h *ManagementHTTPHandler) setSocialCredentials(record *SocialProvider, secret string) error {
|
||||
payload, _ := json.Marshal(socialCredentials{Secret: secret})
|
||||
encrypted, version, err := h.service.idpCipher.Encrypt(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record.EncryptedCredentials, record.CredentialKEKVersion = encrypted, version
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ManagementHTTPHandler) socialProviderView(record SocialProvider) map[string]any {
|
||||
configured := false
|
||||
if plaintext, err := h.service.idpCipher.Decrypt(record.EncryptedCredentials, record.CredentialKEKVersion); err == nil {
|
||||
var credentials socialCredentials
|
||||
configured = json.Unmarshal(plaintext, &credentials) == nil && credentials.Secret != ""
|
||||
}
|
||||
return map[string]any{"id": record.ID, "code": record.Code, "kind": record.Kind, "display_name": record.DisplayName,
|
||||
"client_id": record.ClientID, "agent_id": record.AgentID, "secret_configured": configured,
|
||||
"redirect_uri": record.RedirectURI, "portal_return_url": record.PortalReturnURL,
|
||||
"auto_provision": record.AutoProvision, "default_department_id": record.DefaultDepartmentID,
|
||||
"enabled": record.Enabled, "revision": record.Revision, "created_at": record.CreatedAt, "updated_at": record.UpdatedAt}
|
||||
}
|
||||
|
||||
func (h *ManagementHTTPHandler) listSocialProviders(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePermission(w, r); !ok {
|
||||
return
|
||||
}
|
||||
records, err := h.service.repository.ListSocialProviders(r.Context())
|
||||
if err != nil {
|
||||
h.writeError(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(records))
|
||||
for _, record := range records {
|
||||
items = append(items, h.socialProviderView(record))
|
||||
}
|
||||
apiresponse.OK(w, items)
|
||||
}
|
||||
|
||||
func (h *ManagementHTTPHandler) createSocialProvider(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := h.requirePermission(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
kind := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("kind")))
|
||||
if !socialKindSupported(kind) {
|
||||
apiresponse.Error(w, 400, "扫码登录平台必须是 wecom/dingtalk/feishu")
|
||||
return
|
||||
}
|
||||
input, record, ok := h.decodeSocialProvider(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if kind == "wecom" && input.AgentID == "" {
|
||||
apiresponse.Error(w, 400, "企业微信身份源需要 AgentID")
|
||||
return
|
||||
}
|
||||
if err := h.setSocialCredentials(&record, strings.TrimSpace(*input.Secret)); err != nil {
|
||||
h.writeError(w, err)
|
||||
return
|
||||
}
|
||||
record.Kind = kind
|
||||
created, err := h.service.repository.SaveSocialProvider(r.Context(), record, actor.ID, true, true)
|
||||
if err != nil {
|
||||
h.writeError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, h.socialProviderView(created))
|
||||
}
|
||||
|
||||
func (h *ManagementHTTPHandler) updateSocialProvider(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := h.requirePermission(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
kind := strings.ToLower(strings.TrimSpace(r.PathValue("kind")))
|
||||
if !socialKindSupported(kind) {
|
||||
apiresponse.Error(w, 400, "扫码登录平台必须是 wecom/dingtalk/feishu")
|
||||
return
|
||||
}
|
||||
existing, err := h.service.repository.GetSocialProviderByKind(r.Context(), kind)
|
||||
if err != nil {
|
||||
h.writeError(w, err)
|
||||
return
|
||||
}
|
||||
input, record, ok := h.decodeSocialProvider(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if kind == "wecom" && input.AgentID == "" {
|
||||
apiresponse.Error(w, 400, "企业微信身份源需要 AgentID")
|
||||
return
|
||||
}
|
||||
record.ID = existing.ID
|
||||
record.Kind = kind
|
||||
replace := input.Secret != nil
|
||||
if replace {
|
||||
if err := h.setSocialCredentials(&record, strings.TrimSpace(*input.Secret)); err != nil {
|
||||
h.writeError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
updated, err := h.service.repository.SaveSocialProvider(r.Context(), record, actor.ID, false, replace)
|
||||
if err != nil {
|
||||
h.writeError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, h.socialProviderView(updated))
|
||||
}
|
||||
|
||||
func (h *ManagementHTTPHandler) deleteSocialProvider(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePermission(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.repository.DeleteSocialProvider(r.Context(), r.PathValue("kind")); err != nil {
|
||||
h.writeError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"deleted": true})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"aigateway.local/core/internal/platform/apiresponse"
|
||||
)
|
||||
|
||||
// registerSocial 注册扫码登录的公开与已认证端点。
|
||||
// 公开入口复用 SSO 的 start/callback 路径(按 provider kind 分发);
|
||||
// 绑定/解绑/绑定列表挂在 portal 账号安全页面。
|
||||
func (h *HTTPHandler) registerSocial() {
|
||||
h.mux.HandleFunc("POST /api/v1/portal/social/{kind}/bind/start", h.bindStart)
|
||||
h.mux.HandleFunc("DELETE /api/v1/portal/social/{kind}/bind", h.unbind)
|
||||
h.mux.HandleFunc("GET /api/v1/portal/social/bindings", h.bindings)
|
||||
}
|
||||
|
||||
// startSocial 处理扫码登录的 start 分发(由 startSSO 按 kind 调用)。
|
||||
func (h *HTTPHandler) startSocial(w http.ResponseWriter, r *http.Request) {
|
||||
provider, err := h.service.repository.GetSocialProviderByCode(r.Context(), r.PathValue("provider_code"))
|
||||
if err != nil || !provider.Enabled {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
redirectURL, err := h.service.SocialLoginURL(r.Context(), provider.Kind, "login", "")
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// callbackSocial 处理扫码登录回调:成功后 302 回门户 return_url 并携带
|
||||
// sso_code(登录)或 bind_result(绑定),失败携带 sso_error。
|
||||
func (h *HTTPHandler) callbackSocial(w http.ResponseWriter, r *http.Request) {
|
||||
provider, err := h.service.repository.GetSocialProviderByCode(r.Context(), r.PathValue("provider_code"))
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, "登录方式不存在")
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("error") != "" {
|
||||
h.socialRedirect(w, r, provider, "sso_error", "企业登录已取消或拒绝")
|
||||
return
|
||||
}
|
||||
state := strings.TrimSpace(r.URL.Query().Get("state"))
|
||||
code := strings.TrimSpace(r.URL.Query().Get("code"))
|
||||
if state == "" || code == "" {
|
||||
h.socialRedirect(w, r, provider, "sso_error", "登录回调参数无效")
|
||||
return
|
||||
}
|
||||
result, err := h.service.CompleteSocialLogin(r.Context(), provider.Kind, state, code, SessionMeta{IP: h.service.ClientIP(r), UserAgent: r.UserAgent()})
|
||||
if err != nil {
|
||||
h.socialRedirect(w, r, provider, "sso_error", err.Error())
|
||||
return
|
||||
}
|
||||
switch result.Purpose {
|
||||
case "bind":
|
||||
if result.BindConflict {
|
||||
h.socialRedirect(w, r, provider, "bind_result", "conflict")
|
||||
return
|
||||
}
|
||||
h.socialRedirect(w, r, provider, "bind_result", "ok")
|
||||
case "login":
|
||||
h.socialRedirect(w, r, provider, "sso_code", result.SSOCode)
|
||||
}
|
||||
}
|
||||
|
||||
// socialRedirect 302 到门户 return_url 并携带结果参数。
|
||||
func (h *HTTPHandler) socialRedirect(w http.ResponseWriter, r *http.Request, provider SocialProvider, key, value string) {
|
||||
target, err := url.Parse(provider.PortalReturnURL)
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusBadGateway, "门户返回地址无效")
|
||||
return
|
||||
}
|
||||
query := target.Query()
|
||||
query.Set(key, value)
|
||||
target.RawQuery = query.Encode()
|
||||
http.Redirect(w, r, target.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
// bindStart 已认证用户发起扫码绑定:返回跳转企业身份源的 URL。
|
||||
func (h *HTTPHandler) bindStart(w http.ResponseWriter, r *http.Request) {
|
||||
account, ok := h.requireAccount(w, r, KindPortal)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
redirectURL, err := h.service.SocialLoginURL(r.Context(), r.PathValue("kind"), "bind", account.ID)
|
||||
if err != nil {
|
||||
apiresponse.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]string{"redirect_url": redirectURL})
|
||||
}
|
||||
|
||||
// unbind 解除扫码绑定(仅本人)。
|
||||
func (h *HTTPHandler) unbind(w http.ResponseWriter, r *http.Request) {
|
||||
account, ok := h.requireAccount(w, r, KindPortal)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.UnbindProvider(r.Context(), account.ID, r.PathValue("kind")); err != nil {
|
||||
h.writeIdentityError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"unbound": true})
|
||||
}
|
||||
|
||||
// bindings 返回账号的扫码绑定列表。
|
||||
func (h *HTTPHandler) bindings(w http.ResponseWriter, r *http.Request) {
|
||||
account, ok := h.requireAccount(w, r, KindPortal)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.service.ProviderBindings(r.Context(), account.ID)
|
||||
if err != nil {
|
||||
h.writeIdentityError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, items)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
platformid "aigateway.local/core/internal/platform/id"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// SocialProvider 是内置扫码登录身份源(企微/钉钉/飞书)。
|
||||
// 复用 identity_providers 表:client_id 存平台 AppID(企微为 corp_id),
|
||||
// encrypted_credentials 加密存放 AppSecret,agent_id 等平台特有参数放 config jsonb。
|
||||
type SocialProvider struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Kind string `json:"kind"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ClientID string `json:"client_id"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
EncryptedCredentials []byte `json:"-"`
|
||||
CredentialKEKVersion int `json:"-"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
PortalReturnURL string `json:"portal_return_url"`
|
||||
AutoProvision bool `json:"auto_provision"`
|
||||
DefaultDepartmentID *string `json:"default_department_id,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Revision int64 `json:"revision"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type socialCredentials struct {
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
// ProviderBinding 是门户账号与企微/钉钉/飞书账号的绑定关系。
|
||||
type ProviderBinding struct {
|
||||
Kind string `json:"kind"`
|
||||
ProviderUID string `json:"provider_uid"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
const socialKinds = "('wecom','dingtalk','feishu')"
|
||||
|
||||
func scanSocialProvider(row pgx.Row) (SocialProvider, error) {
|
||||
var p SocialProvider
|
||||
var config []byte
|
||||
var defaultDepartment *string
|
||||
err := row.Scan(&p.ID, &p.Code, &p.DisplayName, &p.Kind, &p.ClientID, &config, &p.EncryptedCredentials, &p.CredentialKEKVersion, &p.RedirectURI, &p.PortalReturnURL, &p.AutoProvision, &defaultDepartment, &p.Enabled, &p.Revision, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return p, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return p, mapRepositoryError(err)
|
||||
}
|
||||
p.DefaultDepartmentID = defaultDepartment
|
||||
var values map[string]string
|
||||
if json.Unmarshal(config, &values) == nil {
|
||||
p.AgentID = values["agent_id"]
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ListSocialProviders 返回全部扫码登录身份源。
|
||||
func (r *Repository) ListSocialProviders(ctx context.Context) ([]SocialProvider, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT id::text,code,display_name,kind,client_id,config,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,auto_provision,default_department_id::text,enabled,revision,created_at,updated_at FROM gateway.identity_providers WHERE kind IN `+socialKinds+` ORDER BY kind,code`)
|
||||
if err != nil {
|
||||
return nil, mapRepositoryError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []SocialProvider{}
|
||||
for rows.Next() {
|
||||
p, err := scanSocialProvider(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, p)
|
||||
}
|
||||
return items, mapRepositoryError(rows.Err())
|
||||
}
|
||||
|
||||
// GetSocialProviderByKind 按 kind 返回扫码登录身份源。
|
||||
func (r *Repository) GetSocialProviderByKind(ctx context.Context, kind string) (SocialProvider, error) {
|
||||
return scanSocialProvider(r.pool.QueryRow(ctx, `SELECT id::text,code,display_name,kind,client_id,config,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,auto_provision,default_department_id::text,enabled,revision,created_at,updated_at FROM gateway.identity_providers WHERE kind=$1`, strings.ToLower(strings.TrimSpace(kind))))
|
||||
}
|
||||
|
||||
// GetSocialProviderByCode 按 SSO 代码返回扫码登录身份源(start/callback 分发用)。
|
||||
func (r *Repository) GetSocialProviderByCode(ctx context.Context, code string) (SocialProvider, error) {
|
||||
return scanSocialProvider(r.pool.QueryRow(ctx, `SELECT id::text,code,display_name,kind,client_id,config,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,auto_provision,default_department_id::text,enabled,revision,created_at,updated_at FROM gateway.identity_providers WHERE code=$1 AND kind IN `+socialKinds, strings.ToLower(strings.TrimSpace(code))))
|
||||
}
|
||||
|
||||
// SaveSocialProvider 创建/更新扫码登录身份源;replaceSecret=false 时保留原 Secret。
|
||||
func (r *Repository) SaveSocialProvider(ctx context.Context, p SocialProvider, actorID string, creating, replaceSecret bool) (SocialProvider, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return p, ErrUnavailable
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if creating {
|
||||
id, err := platformid.NewUUID()
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.ID = id
|
||||
err = tx.QueryRow(ctx, `INSERT INTO gateway.identity_providers(id,code,kind,display_name,client_id,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,auto_provision,default_department_id,enabled,config) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING revision,created_at,updated_at`,
|
||||
p.ID, p.Code, p.Kind, p.DisplayName, p.ClientID, p.EncryptedCredentials, p.CredentialKEKVersion, p.RedirectURI, p.PortalReturnURL, p.AutoProvision, p.DefaultDepartmentID, p.Enabled, agentConfigJSON(p.AgentID)).Scan(&p.Revision, &p.CreatedAt, &p.UpdatedAt)
|
||||
} else {
|
||||
err = tx.QueryRow(ctx, `UPDATE gateway.identity_providers SET code=$2,display_name=$3,client_id=$4,encrypted_credentials=CASE WHEN $13 THEN $5 ELSE encrypted_credentials END,credential_kek_version=CASE WHEN $13 THEN $6 ELSE credential_kek_version END,redirect_uri=$7,portal_return_url=$8,auto_provision=$9,default_department_id=$10,enabled=$11,config=$12,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1 AND kind IN `+socialKinds+` RETURNING encrypted_credentials,credential_kek_version,revision,created_at,updated_at`,
|
||||
p.ID, p.Code, p.DisplayName, p.ClientID, p.EncryptedCredentials, p.CredentialKEKVersion, p.RedirectURI, p.PortalReturnURL, p.AutoProvision, p.DefaultDepartmentID, p.Enabled, agentConfigJSON(p.AgentID), replaceSecret).Scan(&p.EncryptedCredentials, &p.CredentialKEKVersion, &p.Revision, &p.CreatedAt, &p.UpdatedAt)
|
||||
}
|
||||
if err != nil {
|
||||
return p, mapManagementError(err)
|
||||
}
|
||||
eventID, _ := platformid.NewUUID()
|
||||
eventType := "identity_provider.updated"
|
||||
if creating {
|
||||
eventType = "identity_provider.created"
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"identity_provider_id": p.ID, "actor_id": actorID})
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,'identity_provider',$3,$4)`, eventID, eventType, p.ID, payload); err != nil {
|
||||
return p, ErrUnavailable
|
||||
}
|
||||
if tx.Commit(ctx) != nil {
|
||||
return p, ErrUnavailable
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// DeleteSocialProvider 删除扫码登录身份源及其全部绑定。
|
||||
func (r *Repository) DeleteSocialProvider(ctx context.Context, kind string) error {
|
||||
kind = strings.ToLower(strings.TrimSpace(kind))
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var id string
|
||||
if err = tx.QueryRow(ctx, `DELETE FROM gateway.identity_providers WHERE kind=$1 RETURNING id::text`, kind).Scan(&id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return mapManagementError(err)
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `DELETE FROM gateway.portal_user_provider_bindings WHERE provider_kind=$1`, kind); err != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
eventID, _ := platformid.NewUUID()
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'identity_provider.deleted',1,'identity_provider',$2,$3)`, eventID, id, `{"identity_provider_id":"`+id+`"}`); err != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
return mapManagementError(tx.Commit(ctx))
|
||||
}
|
||||
|
||||
func agentConfigJSON(agentID string) []byte {
|
||||
if strings.TrimSpace(agentID) == "" {
|
||||
return []byte(`{}`)
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]string{"agent_id": strings.TrimSpace(agentID)})
|
||||
return raw
|
||||
}
|
||||
|
||||
// FindProviderBinding 按 (kind, uid) 反查门户账号;未绑定返回 ErrNotFound。
|
||||
func (r *Repository) FindProviderBinding(ctx context.Context, kind, uid string) (string, error) {
|
||||
var id string
|
||||
err := r.pool.QueryRow(ctx, `SELECT portal_user_id::text FROM gateway.portal_user_provider_bindings WHERE provider_kind=$1 AND provider_uid=$2`, strings.ToLower(strings.TrimSpace(kind)), uid).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return id, mapRepositoryError(err)
|
||||
}
|
||||
|
||||
// BindProvider 建立绑定。kind+uid 冲突(已被他人绑定)返回错误,账号重复绑定同一
|
||||
// 平台(unique)冲突时先解绑旧绑定再写入,保证一个账号每平台至多一个绑定。
|
||||
func (r *Repository) BindProvider(ctx context.Context, portalUserID, kind, uid string) error {
|
||||
kind = strings.ToLower(strings.TrimSpace(kind))
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err = tx.Exec(ctx, `DELETE FROM gateway.portal_user_provider_bindings WHERE portal_user_id=$1 AND provider_kind=$2`, portalUserID, kind); err != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `INSERT INTO gateway.portal_user_provider_bindings(portal_user_id,provider_kind,provider_uid) VALUES($1,$2,$3) ON CONFLICT(provider_kind,provider_uid) DO NOTHING`, portalUserID, kind, uid)
|
||||
if err != nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errors.New("该平台账号已被其他本系统账号绑定")
|
||||
}
|
||||
return mapManagementError(tx.Commit(ctx))
|
||||
}
|
||||
|
||||
// UnbindProvider 解除绑定(仅本人)。
|
||||
func (r *Repository) UnbindProvider(ctx context.Context, portalUserID, kind string) error {
|
||||
_, err := r.pool.Exec(ctx, `DELETE FROM gateway.portal_user_provider_bindings WHERE portal_user_id=$1 AND provider_kind=$2`, portalUserID, strings.ToLower(strings.TrimSpace(kind)))
|
||||
return mapRepositoryError(err)
|
||||
}
|
||||
|
||||
// ListProviderBindings 返回账号的全部扫码绑定。
|
||||
func (r *Repository) ListProviderBindings(ctx context.Context, portalUserID string) ([]ProviderBinding, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT provider_kind,provider_uid,created_at FROM gateway.portal_user_provider_bindings WHERE portal_user_id=$1 ORDER BY provider_kind`, portalUserID)
|
||||
if err != nil {
|
||||
return nil, mapRepositoryError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ProviderBinding{}
|
||||
for rows.Next() {
|
||||
var item ProviderBinding
|
||||
if err := rows.Scan(&item.Kind, &item.ProviderUID, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, mapRepositoryError(rows.Err())
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"aigateway.local/core/internal/platform/cryptox"
|
||||
)
|
||||
|
||||
// hostRouter 把固定平台域名路由到本地的 httptest 服务,验证三个平台的
|
||||
// code 换取身份协议(端点、请求体、响应字段)。
|
||||
type hostRouter struct {
|
||||
targets map[string]string
|
||||
inner *http.Transport
|
||||
}
|
||||
|
||||
func (r hostRouter) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
base, ok := r.targets[request.URL.Host]
|
||||
if !ok {
|
||||
return nil, errors.New("unexpected host " + request.URL.Host)
|
||||
}
|
||||
target, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clone := request.Clone(request.Context())
|
||||
clone.URL.Scheme = target.Scheme
|
||||
clone.URL.Host = target.Host
|
||||
return r.inner.RoundTrip(clone)
|
||||
}
|
||||
|
||||
func testSocialService(t *testing.T, targets map[string]string) *Service {
|
||||
t.Helper()
|
||||
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
|
||||
cipher, err := cryptox.NewAESGCM(key, 1, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Service{
|
||||
idpCipher: cipher,
|
||||
oidcClient: &http.Client{Transport: hostRouter{targets: targets, inner: http.DefaultTransport.(*http.Transport).Clone()}},
|
||||
}
|
||||
}
|
||||
|
||||
func socialProviderWithSecret(t *testing.T, service *Service, kind, clientID string, secret string) SocialProvider {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(socialCredentials{Secret: secret})
|
||||
encrypted, version, err := service.idpCipher.Encrypt(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return SocialProvider{Kind: kind, ClientID: clientID, EncryptedCredentials: encrypted, CredentialKEKVersion: version}
|
||||
}
|
||||
|
||||
func TestExchangeWeCom(t *testing.T) {
|
||||
tokenCalls := 0
|
||||
userCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/cgi-bin/gettoken"):
|
||||
tokenCalls++
|
||||
if r.URL.Query().Get("corpid") != "corp-1" || r.URL.Query().Get("corpsecret") != "s3cret" {
|
||||
t.Errorf("gettoken query = %v", r.URL.RawQuery)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"errcode": 0, "access_token": "token-1"})
|
||||
case strings.HasPrefix(r.URL.Path, "/cgi-bin/auth/getuserinfo"):
|
||||
userCalls++
|
||||
if r.URL.Query().Get("code") != "code-x" {
|
||||
t.Errorf("getuserinfo code = %q", r.URL.Query().Get("code"))
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"errcode": 0, "userid": "zhangsan", "openid": "open-1"})
|
||||
default:
|
||||
t.Errorf("unexpected wecom path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := testSocialService(t, map[string]string{"qyapi.weixin.qq.com": server.URL})
|
||||
identity, err := service.exchangeSocial(context.Background(), socialProviderWithSecret(t, service, "wecom", "corp-1", "s3cret"), "code-x")
|
||||
if err != nil {
|
||||
t.Fatalf("wecom exchange failed: %v", err)
|
||||
}
|
||||
if identity.UID != "zhangsan" {
|
||||
t.Errorf("uid = %q, want zhangsan", identity.UID)
|
||||
}
|
||||
if tokenCalls != 1 || userCalls != 1 {
|
||||
t.Errorf("calls token=%d user=%d, want 1/1", tokenCalls, userCalls)
|
||||
}
|
||||
|
||||
// 企业外成员只有 openid 时回退 openid。
|
||||
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/cgi-bin/gettoken") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"errcode": 0, "access_token": "token-2"})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"errcode": 0, "userid": "", "openid": "open-2"})
|
||||
}))
|
||||
defer server2.Close()
|
||||
service2 := testSocialService(t, map[string]string{"qyapi.weixin.qq.com": server2.URL})
|
||||
identity2, err := service2.exchangeSocial(context.Background(), socialProviderWithSecret(t, service2, "wecom", "corp-1", "s3cret"), "code-x")
|
||||
if err != nil {
|
||||
t.Fatalf("wecom openid fallback failed: %v", err)
|
||||
}
|
||||
if identity2.UID != "open-2" {
|
||||
t.Errorf("uid = %q, want open-2", identity2.UID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeDingTalk(t *testing.T) {
|
||||
var tokenBody map[string]string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/v1.0/oauth2/userAccessToken"):
|
||||
if err := json.NewDecoder(r.Body).Decode(&tokenBody); err != nil {
|
||||
t.Errorf("decode token body: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"accessToken": "dt-token"})
|
||||
case strings.HasPrefix(r.URL.Path, "/v1.0/contact/users/me"):
|
||||
if r.Header.Get("x-acs-dingtalk-access-token") != "dt-token" {
|
||||
t.Errorf("missing x-acs-dingtalk-access-token header")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"unionId": "union-9", "openId": "open-9", "nick": "张三"})
|
||||
default:
|
||||
t.Errorf("unexpected dingtalk path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := testSocialService(t, map[string]string{"api.dingtalk.com": server.URL})
|
||||
identity, err := service.exchangeSocial(context.Background(), socialProviderWithSecret(t, service, "dingtalk", "app-key", "app-secret"), "code-d")
|
||||
if err != nil {
|
||||
t.Fatalf("dingtalk exchange failed: %v", err)
|
||||
}
|
||||
if identity.UID != "union-9" || identity.Name != "张三" {
|
||||
t.Errorf("uid=%q name=%q", identity.UID, identity.Name)
|
||||
}
|
||||
if tokenBody["grantType"] != "authorization_code" || tokenBody["clientId"] != "app-key" {
|
||||
t.Errorf("token body = %v", tokenBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeFeishu(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/open-apis/authen/v1/oidc/access_token"):
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("decode body: %v", err)
|
||||
}
|
||||
if body["grant_type"] != "authorization_code" || body["app_id"] != "app-1" {
|
||||
t.Errorf("token body = %v", body)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"access_token": "fs-token"}})
|
||||
case strings.HasPrefix(r.URL.Path, "/open-apis/authen/v1/user_info"):
|
||||
if r.Header.Get("Authorization") != "Bearer fs-token" {
|
||||
t.Errorf("missing bearer token")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "data": map[string]any{"name": "李四", "open_id": "ou_1", "union_id": "on_1"}})
|
||||
default:
|
||||
t.Errorf("unexpected feishu path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := testSocialService(t, map[string]string{"open.feishu.cn": server.URL})
|
||||
identity, err := service.exchangeSocial(context.Background(), socialProviderWithSecret(t, service, "feishu", "app-1", "app-secret"), "code-f")
|
||||
if err != nil {
|
||||
t.Fatalf("feishu exchange failed: %v", err)
|
||||
}
|
||||
if identity.UID != "on_1" || identity.Name != "李四" {
|
||||
t.Errorf("uid=%q name=%q", identity.UID, identity.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeSocialRejectsPlatformErrors(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"errcode": 40013, "errmsg": "invalid corpsecret"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
service := testSocialService(t, map[string]string{"qyapi.weixin.qq.com": server.URL})
|
||||
if _, err := service.exchangeSocial(context.Background(), socialProviderWithSecret(t, service, "wecom", "corp-1", "bad"), "code-x"); err == nil {
|
||||
t.Fatal("expected error for platform errcode != 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSocialKindSupported(t *testing.T) {
|
||||
for _, kind := range []string{"wecom", "dingtalk", "feishu", "WECOM", " DingTalk "} {
|
||||
if !socialKindSupported(kind) {
|
||||
t.Errorf("kind %q should be supported (case/space normalized)", kind)
|
||||
}
|
||||
}
|
||||
for _, kind := range []string{"oidc", "saml", "", "weixin"} {
|
||||
if socialKindSupported(kind) {
|
||||
t.Errorf("kind %q should not be supported", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package portal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"aigateway.local/core/internal/gateway"
|
||||
"aigateway.local/core/internal/identity"
|
||||
platformid "aigateway.local/core/internal/platform/id"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ChatModel 是门户用户经审批可用的模型。
|
||||
type ChatModel struct {
|
||||
ProviderCode string `json:"provider_code"`
|
||||
Model string `json:"model"`
|
||||
ApprovedAt time.Time `json:"approved_at"`
|
||||
}
|
||||
|
||||
// ChatModels 返回该用户所有已批准且供应商/模型仍启用的模型。
|
||||
func (s *Service) ChatModels(ctx context.Context, account identity.Account) ([]ChatModel, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT DISTINCT r.provider_code,r.model,max(r.decided_at)
|
||||
FROM gateway.model_access_requests r
|
||||
JOIN gateway.providers p ON p.code=r.provider_code AND p.enabled
|
||||
JOIN gateway.provider_models m ON m.provider_id=p.id AND m.provider_model_id=r.model AND m.enabled
|
||||
WHERE r.portal_user_id=$1 AND r.status='approved'
|
||||
GROUP BY r.provider_code,r.model ORDER BY r.provider_code,r.model`, account.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ChatModel{}
|
||||
for rows.Next() {
|
||||
var item ChatModel
|
||||
if err := rows.Scan(&item.ProviderCode, &item.Model, &item.ApprovedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// approvedModel 校验模型是否在该用户的已批准清单内。
|
||||
func (s *Service) approvedModel(ctx context.Context, account identity.Account, providerCode, model string) (ChatModel, error) {
|
||||
providerCode = strings.ToLower(strings.TrimSpace(providerCode))
|
||||
model = strings.TrimSpace(model)
|
||||
var item ChatModel
|
||||
err := s.pool.QueryRow(ctx, `SELECT r.provider_code,r.model,r.decided_at
|
||||
FROM gateway.model_access_requests r
|
||||
JOIN gateway.providers p ON p.code=r.provider_code AND p.enabled
|
||||
JOIN gateway.provider_models m ON m.provider_id=p.id AND m.provider_model_id=r.model AND m.enabled
|
||||
WHERE r.portal_user_id=$1 AND r.provider_code=$2 AND r.model=$3 AND r.status='approved'
|
||||
ORDER BY r.decided_at DESC LIMIT 1`, account.ID, providerCode, model).Scan(&item.ProviderCode, &item.Model, &item.ApprovedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ChatModel{}, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
// ensureChatCredential 为用户开通/复用聊天运行时凭据,限额取已批准申请的最大值。
|
||||
func (s *Service) ensureChatCredential(ctx context.Context, account identity.Account) (string, error) {
|
||||
if s.credentials == nil || s.runtime == nil {
|
||||
return "", errors.New("聊天服务未配置")
|
||||
}
|
||||
var rpm int
|
||||
var monthlyTokens int64
|
||||
if err := s.pool.QueryRow(ctx, `SELECT COALESCE(max(requested_rpm),0),COALESCE(max(requested_monthly_tokens),0) FROM gateway.model_access_requests WHERE portal_user_id=$1 AND status='approved'`, account.ID).Scan(&rpm, &monthlyTokens); err != nil {
|
||||
return "", err
|
||||
}
|
||||
secret, _, err := s.credentials.EnsureUser(ctx, account.ID, account.DepartmentID, rpm, monthlyTokens)
|
||||
return secret, err
|
||||
}
|
||||
|
||||
// ChatSession 是一条通用聊天会话。
|
||||
type ChatSession struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ProviderCode string `json:"provider_code"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
Messages []ConversationMessage `json:"messages,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
const chatSessionSelect = `SELECT id::text,title,provider_code,model,status,created_at,updated_at FROM gateway.portal_chat_sessions`
|
||||
|
||||
func (s *Service) ListChatSessions(ctx context.Context, account identity.Account, limit int) ([]ChatSession, error) {
|
||||
if limit < 1 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, chatSessionSelect+` WHERE portal_user_id=$1 AND status='active' ORDER BY updated_at DESC LIMIT $2`, account.ID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ChatSession{}
|
||||
for rows.Next() {
|
||||
var item ChatSession
|
||||
if err := rows.Scan(&item.ID, &item.Title, &item.ProviderCode, &item.Model, &item.Status, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) CreateChatSession(ctx context.Context, account identity.Account, providerCode, model string) (ChatSession, error) {
|
||||
if _, err := s.approvedModel(ctx, account, providerCode, model); err != nil {
|
||||
return ChatSession{}, ErrNotFound
|
||||
}
|
||||
if _, err := s.ensureChatCredential(ctx, account); err != nil {
|
||||
return ChatSession{}, err
|
||||
}
|
||||
id, err := platformid.NewUUID()
|
||||
if err != nil {
|
||||
return ChatSession{}, err
|
||||
}
|
||||
var item ChatSession
|
||||
err = s.pool.QueryRow(ctx, `INSERT INTO gateway.portal_chat_sessions(id,portal_user_id,provider_code,model) VALUES($1,$2,$3,$4) RETURNING id::text,'',provider_code,model,status,created_at,updated_at`, id, account.ID, providerCode, model).Scan(&item.ID, &item.Title, &item.ProviderCode, &item.Model, &item.Status, &item.CreatedAt, &item.UpdatedAt)
|
||||
item.Messages = []ConversationMessage{}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) RenameChatSession(ctx context.Context, account identity.Account, id, title string) (ChatSession, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" || len(title) > 128 {
|
||||
return ChatSession{}, errors.New("会话标题必须为 1-128 个字符")
|
||||
}
|
||||
var item ChatSession
|
||||
err := s.pool.QueryRow(ctx, `UPDATE gateway.portal_chat_sessions SET title=$3,updated_at=clock_timestamp() WHERE id=$1 AND portal_user_id=$2 AND status='active' RETURNING id::text,title,provider_code,model,status,created_at,updated_at`, id, account.ID, title).Scan(&item.ID, &item.Title, &item.ProviderCode, &item.Model, &item.Status, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ChatSession{}, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) DeleteChatSession(ctx context.Context, account identity.Account, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE gateway.portal_chat_sessions SET status='archived' WHERE id=$1 AND portal_user_id=$2`, id, account.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChatSession 返回会话与全部消息(哈希链完整性校验)。
|
||||
func (s *Service) ChatSession(ctx context.Context, account identity.Account, id string) (ChatSession, error) {
|
||||
var item ChatSession
|
||||
err := s.pool.QueryRow(ctx, chatSessionSelect+` WHERE id=$1 AND portal_user_id=$2`, id, account.ID).Scan(&item.ID, &item.Title, &item.ProviderCode, &item.Model, &item.Status, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ChatSession{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ChatSession{}, err
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `SELECT sequence,role,content,previous_hash,message_hash,created_at FROM gateway.portal_chat_messages WHERE session_id=$1 ORDER BY sequence`, id)
|
||||
if err != nil {
|
||||
return ChatSession{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
previous := strings.Repeat("0", 64)
|
||||
item.Messages = []ConversationMessage{}
|
||||
for rows.Next() {
|
||||
var message ConversationMessage
|
||||
var storedPrevious, storedHash string
|
||||
if err = rows.Scan(&message.Sequence, &message.Role, &message.Content, &storedPrevious, &storedHash, &message.CreatedAt); err != nil {
|
||||
return ChatSession{}, err
|
||||
}
|
||||
if storedPrevious != previous || storedHash != messageDigest(previous, message.Sequence, message.Role, message.Content) {
|
||||
return ChatSession{}, errors.New("会话历史完整性校验失败")
|
||||
}
|
||||
previous = storedHash
|
||||
item.Messages = append(item.Messages, message)
|
||||
}
|
||||
return item, rows.Err()
|
||||
}
|
||||
|
||||
// appendChatMessage 在会话上追加一条消息(哈希链 + 序号,事务内完成)。
|
||||
func (s *Service) appendChatMessage(ctx context.Context, sessionID, role, content string) (ConversationMessage, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return ConversationMessage{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
var sequence int
|
||||
if err = tx.QueryRow(ctx, `SELECT next_sequence FROM gateway.portal_chat_sessions WHERE id=$1 FOR UPDATE`, sessionID).Scan(&sequence); err != nil {
|
||||
return ConversationMessage{}, err
|
||||
}
|
||||
if sequence > 200 {
|
||||
return ConversationMessage{}, errors.New("本会话已达到 200 条消息上限")
|
||||
}
|
||||
previous := strings.Repeat("0", 64)
|
||||
if sequence > 1 {
|
||||
if err = tx.QueryRow(ctx, `SELECT message_hash FROM gateway.portal_chat_messages WHERE session_id=$1 AND sequence=$2`, sessionID, sequence-1).Scan(&previous); err != nil {
|
||||
return ConversationMessage{}, err
|
||||
}
|
||||
}
|
||||
id, err := platformid.NewUUID()
|
||||
if err != nil {
|
||||
return ConversationMessage{}, err
|
||||
}
|
||||
hash := messageDigest(previous, sequence, role, content)
|
||||
var created time.Time
|
||||
if err = tx.QueryRow(ctx, `INSERT INTO gateway.portal_chat_messages(id,session_id,sequence,role,content,previous_hash,message_hash) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING created_at`, id, sessionID, sequence, role, content, previous, hash).Scan(&created); err != nil {
|
||||
return ConversationMessage{}, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE gateway.portal_chat_sessions SET next_sequence=next_sequence+1,title=CASE WHEN next_sequence=1 THEN left($2,160) ELSE title END,updated_at=clock_timestamp() WHERE id=$1`, sessionID, content)
|
||||
if err != nil {
|
||||
return ConversationMessage{}, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return ConversationMessage{}, err
|
||||
}
|
||||
return ConversationMessage{Sequence: sequence, Role: role, Content: content, CreatedAt: created}, nil
|
||||
}
|
||||
|
||||
// callChat 用用户的运行时凭据直接调用受管网关 /v1/chat/completions。
|
||||
// 认证、限流、配额、审计与路由都由网关统一执行,与外部 API Key 调用完全同权。
|
||||
func (s *Service) callChat(ctx context.Context, secret, providerCode, model string, messages []ConversationMessage) (map[string]any, string, error) {
|
||||
payloadMessages := make([]map[string]any, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
payloadMessages = append(payloadMessages, map[string]any{"role": m.Role, "content": m.Content})
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"model": model, "messages": payloadMessages, "stream": false})
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(payload)).WithContext(gateway.WithRequestID(ctx, "portal-chat-"+time.Now().UTC().Format("20060102150405.000000000")))
|
||||
request.Header.Set("Authorization", "Bearer "+secret)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
s.gateway.ServeHTTP(recorder, request)
|
||||
var response map[string]any
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
return nil, "", errors.New("模型响应无法解析")
|
||||
}
|
||||
if recorder.Code < 200 || recorder.Code >= 300 {
|
||||
message := fmt.Sprintf("模型调用失败(HTTP %d)", recorder.Code)
|
||||
if value, ok := response["error"].(map[string]any); ok {
|
||||
if text, ok := value["message"].(string); ok {
|
||||
message = text
|
||||
}
|
||||
}
|
||||
return response, "", errors.New(message)
|
||||
}
|
||||
choices, _ := response["choices"].([]any)
|
||||
if len(choices) == 0 {
|
||||
return response, "", errors.New("模型未返回回答")
|
||||
}
|
||||
choice, _ := choices[0].(map[string]any)
|
||||
message, _ := choice["message"].(map[string]any)
|
||||
answer, _ := message["content"].(string)
|
||||
if strings.TrimSpace(answer) == "" {
|
||||
return response, "", errors.New("模型未返回文本回答")
|
||||
}
|
||||
return response, answer, nil
|
||||
}
|
||||
|
||||
// ChatOnce 一次性对话(不落库):模型须已批准,凭据自动开通。
|
||||
func (s *Service) ChatOnce(ctx context.Context, account identity.Account, providerCode, model, message string) (map[string]any, error) {
|
||||
if _, err := s.approvedModel(ctx, account, providerCode, model); err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" || len(message) > 100000 {
|
||||
return nil, errors.New("消息为空或过长")
|
||||
}
|
||||
secret, err := s.ensureChatCredential(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, _, err := s.callChat(ctx, secret, providerCode, model, []ConversationMessage{{Role: "user", Content: message}})
|
||||
return response, err
|
||||
}
|
||||
|
||||
// AppendChatMessage 在会话上追加一轮对话:busy 租约防并发交错,消息在模型
|
||||
// 调用成功后才落库,失败重试不会产生孤儿消息或重复消息。
|
||||
func (s *Service) AppendChatMessage(ctx context.Context, account identity.Account, id, message string) (map[string]any, error) {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" || len(message) > 100000 {
|
||||
return nil, errors.New("消息为空或过长")
|
||||
}
|
||||
lease, _ := platformid.NewUUID()
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE gateway.portal_chat_sessions SET busy=true,busy_token=$3,busy_since=clock_timestamp() WHERE id=$1 AND portal_user_id=$2 AND status='active' AND (NOT busy OR busy_since<clock_timestamp()-interval '10 minutes')`, id, account.ID, lease)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return nil, errors.New("会话不存在、已归档或上一条消息仍在处理")
|
||||
}
|
||||
defer func() {
|
||||
_, _ = s.pool.Exec(context.WithoutCancel(ctx), `UPDATE gateway.portal_chat_sessions SET busy=false,busy_token=NULL,busy_since=NULL WHERE id=$1 AND busy_token=$2`, id, lease)
|
||||
}()
|
||||
var providerCode, model string
|
||||
if err = s.pool.QueryRow(ctx, `SELECT provider_code,model FROM gateway.portal_chat_sessions WHERE id=$1`, id).Scan(&providerCode, &model); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conversation, err := s.ChatSession(ctx, account, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, err := s.ensureChatCredential(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
history := make([]ConversationMessage, len(conversation.Messages)+1)
|
||||
copy(history, conversation.Messages)
|
||||
history[len(conversation.Messages)] = ConversationMessage{Role: "user", Content: message}
|
||||
response, answer, err := s.callChat(ctx, secret, providerCode, model, history)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
if _, err = s.appendChatMessage(ctx, id, "user", message); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = s.appendChatMessage(ctx, id, "assistant", answer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response["conversation_id"] = id
|
||||
response["history_integrity"] = "verified"
|
||||
return response, nil
|
||||
}
|
||||
@@ -72,6 +72,85 @@ func (s *RuntimeCredentials) Ensure(ctx context.Context, applicationID string, d
|
||||
return secret, record.ID, nil
|
||||
}
|
||||
|
||||
// EnsureUser returns the portal user's personal runtime credential used by the
|
||||
// general chat. It is created lazily on first use with limits derived from the
|
||||
// user's approved model requests, and bound to the user's department tenant so
|
||||
// usage/audit are attributed to the user's own key. Idempotent: the unique
|
||||
// primary key makes concurrent first-use requests converge on one credential.
|
||||
func (s *RuntimeCredentials) EnsureUser(ctx context.Context, userID string, departmentID *string, rpm int, monthlyTokens int64) (string, string, error) {
|
||||
if s == nil || s.pool == nil || s.repository == nil || s.cipher == nil {
|
||||
return "", "", errors.New("runtime credentials unavailable")
|
||||
}
|
||||
var encrypted []byte
|
||||
var version int
|
||||
var keyID string
|
||||
err := s.pool.QueryRow(ctx, `SELECT encrypted_key,key_kek_version,api_key_id::text FROM gateway.portal_user_runtime_credentials WHERE portal_user_id=$1`, userID).Scan(&encrypted, &version, &keyID)
|
||||
if err == nil {
|
||||
plain, decryptErr := s.cipher.Decrypt(encrypted, version)
|
||||
return string(plain), keyID, decryptErr
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", err
|
||||
}
|
||||
if rpm < 1 {
|
||||
rpm = 120
|
||||
}
|
||||
record, secret, err := s.repository.Create(ctx, "portal-chat-runtime", []string{"gateway:invoke"}, rpm, 0, monthlyTokens, nil, "")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
encrypted, version, err = s.cipher.Encrypt([]byte(secret))
|
||||
if err != nil {
|
||||
_, _ = s.repository.Revoke(ctx, record.ID, "")
|
||||
return "", "", err
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
_, _ = s.repository.Revoke(ctx, record.ID, "")
|
||||
return "", "", err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
_, err = tx.Exec(ctx, `UPDATE gateway.api_keys SET portal_user_id=$2,tenant_id=$3 WHERE id=$1`, record.ID, userID, departmentID)
|
||||
if err == nil {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO gateway.portal_user_runtime_credentials(portal_user_id,api_key_id,encrypted_key,key_kek_version) VALUES($1,$2,$3,$4)`, userID, record.ID, encrypted, version)
|
||||
}
|
||||
if err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
_, _ = s.repository.Revoke(ctx, record.ID, "")
|
||||
// A concurrent first-use request may have won the unique-key race.
|
||||
var pgError *pgconn.PgError
|
||||
if errors.As(err, &pgError) && pgError.Code == "23505" {
|
||||
return s.EnsureUser(ctx, userID, departmentID, rpm, monthlyTokens)
|
||||
}
|
||||
return "", "", fmt.Errorf("store portal runtime credential: %w", err)
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
_, _ = s.repository.Revoke(ctx, record.ID, "")
|
||||
return "", "", err
|
||||
}
|
||||
return secret, record.ID, nil
|
||||
}
|
||||
|
||||
// UserSecret returns the portal user's runtime credential plaintext for the
|
||||
// duration of the request. Empty when not yet provisioned.
|
||||
func (s *RuntimeCredentials) UserSecret(ctx context.Context, userID string) (string, string, error) {
|
||||
if s == nil || s.pool == nil || s.cipher == nil {
|
||||
return "", "", errors.New("runtime credentials unavailable")
|
||||
}
|
||||
var encrypted []byte
|
||||
var version int
|
||||
var keyID string
|
||||
err := s.pool.QueryRow(ctx, `SELECT encrypted_key,key_kek_version,api_key_id::text FROM gateway.portal_user_runtime_credentials WHERE portal_user_id=$1`, userID).Scan(&encrypted, &version, &keyID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
plain, err := s.cipher.Decrypt(encrypted, version)
|
||||
return string(plain), keyID, err
|
||||
}
|
||||
|
||||
func (s *RuntimeCredentials) Metadata(ctx context.Context, applicationID string) ([]map[string]any, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT c.api_key_id::text,k.key_prefix,c.department_id::text,k.enabled,c.created_at FROM gateway.application_runtime_credentials c JOIN gateway.api_keys k ON k.id=c.api_key_id WHERE c.application_id=$1 ORDER BY c.created_at`, applicationID)
|
||||
if err != nil {
|
||||
|
||||
@@ -45,6 +45,15 @@ func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHa
|
||||
h.mux.HandleFunc("DELETE /api/v1/portal/apps/{code}/conversations/{id}", h.deleteConversation)
|
||||
h.mux.HandleFunc("GET /api/v1/portal/apps/{code}/conversations/{id}", h.getConversation)
|
||||
h.mux.HandleFunc("POST /api/v1/portal/apps/{code}/conversations/{id}/messages", h.appendConversationMessage)
|
||||
// 通用聊天:选择已批准模型直接对话(复用用户运行时凭据)。
|
||||
h.mux.HandleFunc("GET /api/v1/portal/chat/models", h.chatModels)
|
||||
h.mux.HandleFunc("POST /api/v1/portal/chat/completions", h.chatOnce)
|
||||
h.mux.HandleFunc("GET /api/v1/portal/chat/sessions", h.listChatSessions)
|
||||
h.mux.HandleFunc("POST /api/v1/portal/chat/sessions", h.createChatSession)
|
||||
h.mux.HandleFunc("PATCH /api/v1/portal/chat/sessions/{id}", h.renameChatSession)
|
||||
h.mux.HandleFunc("DELETE /api/v1/portal/chat/sessions/{id}", h.deleteChatSession)
|
||||
h.mux.HandleFunc("GET /api/v1/portal/chat/sessions/{id}", h.getChatSession)
|
||||
h.mux.HandleFunc("POST /api/v1/portal/chat/sessions/{id}/messages", h.appendChatMessage)
|
||||
h.mux.HandleFunc("GET /api/v1/portal/marketplace", h.marketplace)
|
||||
h.mux.HandleFunc("GET /api/v1/portal/marketplace/categories", h.marketplaceCategories)
|
||||
h.mux.HandleFunc("GET /api/v1/portal/marketplace/installed", h.marketplaceInstalled)
|
||||
@@ -589,3 +598,135 @@ func (h *HTTPHandler) appendConversationMessage(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
writeApplicationResponse(w, response)
|
||||
}
|
||||
|
||||
// --- 通用聊天 ---
|
||||
|
||||
type chatCompletionsInput struct {
|
||||
ProviderCode string `json:"provider_code"`
|
||||
Model string `json:"model"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) chatModels(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.account(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.service.ChatModels(r.Context(), a)
|
||||
if err != nil {
|
||||
portalError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, items)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) chatOnce(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.account(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input chatCompletionsInput
|
||||
if !decode(w, r, &input) {
|
||||
return
|
||||
}
|
||||
response, err := h.service.ChatOnce(r.Context(), a, input.ProviderCode, input.Model, input.Message)
|
||||
if err != nil {
|
||||
portalError(w, err)
|
||||
return
|
||||
}
|
||||
writeApplicationResponse(w, response)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) listChatSessions(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.account(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
items, err := h.service.ListChatSessions(r.Context(), a, limit)
|
||||
if err != nil {
|
||||
portalError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, items)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) createChatSession(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.account(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input chatCompletionsInput
|
||||
if !decode(w, r, &input) {
|
||||
return
|
||||
}
|
||||
item, err := h.service.CreateChatSession(r.Context(), a, input.ProviderCode, input.Model)
|
||||
if err != nil {
|
||||
portalError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, item)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) renameChatSession(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.account(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
if !decode(w, r, &input) {
|
||||
return
|
||||
}
|
||||
item, err := h.service.RenameChatSession(r.Context(), a, r.PathValue("id"), input.Title)
|
||||
if err != nil {
|
||||
portalError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, item)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) deleteChatSession(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.account(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.service.DeleteChatSession(r.Context(), a, r.PathValue("id")); err != nil {
|
||||
portalError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) getChatSession(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.account(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.service.ChatSession(r.Context(), a, r.PathValue("id"))
|
||||
if err != nil {
|
||||
portalError(w, err)
|
||||
return
|
||||
}
|
||||
apiresponse.OK(w, item)
|
||||
}
|
||||
|
||||
func (h *HTTPHandler) appendChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := h.account(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if !decode(w, r, &input) {
|
||||
return
|
||||
}
|
||||
response, err := h.service.AppendChatMessage(r.Context(), a, r.PathValue("id"), input.Message)
|
||||
if err != nil {
|
||||
portalError(w, err)
|
||||
return
|
||||
}
|
||||
writeApplicationResponse(w, response)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ type Service struct {
|
||||
identity *identity.Service
|
||||
credentials *RuntimeCredentials
|
||||
runtime http.Handler
|
||||
gateway http.Handler
|
||||
market *workbench.MarketplaceService
|
||||
}
|
||||
|
||||
@@ -41,6 +42,10 @@ func (s *Service) SetApplicationRuntime(credentials *RuntimeCredentials, runtime
|
||||
s.runtime = runtime
|
||||
}
|
||||
|
||||
// SetGateway wires the governed gateway handler so the general chat can call
|
||||
// /v1/chat/completions with the user's own runtime credential.
|
||||
func (s *Service) SetGateway(gateway http.Handler) { s.gateway = gateway }
|
||||
|
||||
// SetMarketplace wires the resource-marketplace service into the portal so the
|
||||
// marketplace pages can browse, install and manage resources.
|
||||
func (s *Service) SetMarketplace(market *workbench.MarketplaceService) {
|
||||
|
||||
@@ -70,6 +70,7 @@ type inboxDraft struct {
|
||||
UserID string // 直接收件人(从 payload 取),空串表示需额外解析
|
||||
AllAdmins bool // 收件人 = 全部启用管理员
|
||||
RequestUser bool // 收件人 = model_access_requests.portal_user_id(payload.request_id)
|
||||
NotifyPref bool // 收件人 = payload.portal_user_id,且其登录通知偏好开启
|
||||
}
|
||||
|
||||
func payloadValue(payload json.RawMessage, key string) string {
|
||||
@@ -115,12 +116,28 @@ func inboxPlan(eventType string, payload json.RawMessage) []inboxDraft {
|
||||
return []inboxDraft{{RecipientKind: "admin", Category: "task_result", Title: "定时任务已执行", Body: "定时任务 " + payloadValue(payload, "task_code") + " 已完成", Link: "/system/scheduled-tasks", UserID: payloadValue(payload, "actor_id")}}
|
||||
case "scheduled_task.failed":
|
||||
return []inboxDraft{{RecipientKind: "admin", Category: "task_result", Title: "定时任务执行失败", Body: "定时任务 " + payloadValue(payload, "task_code") + " 执行失败: " + payloadValue(payload, "error"), Link: "/system/scheduled-tasks", UserID: payloadValue(payload, "actor_id")}}
|
||||
case "security.login_detected":
|
||||
ip := payloadValue(payload, "ip")
|
||||
if ip == "" {
|
||||
ip = "未知地址"
|
||||
}
|
||||
return []inboxDraft{{RecipientKind: "portal", Category: "security", Title: "新设备登录提醒", Body: "你的账号刚刚从 " + ip + " 登录,如非本人操作请立即修改密码", Link: "/portal/security", UserID: payloadValue(payload, "portal_user_id"), NotifyPref: true}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *InboxService) resolveRecipients(ctx context.Context, draft inboxDraft, payload json.RawMessage) ([]string, error) {
|
||||
switch {
|
||||
case draft.UserID != "" && draft.NotifyPref:
|
||||
// 登录提醒:尊重账号的安全偏好(默认开启)。
|
||||
var notify bool
|
||||
if err := s.assets.pool.QueryRow(ctx, `SELECT COALESCE((SELECT login_notify FROM gateway.portal_security_prefs WHERE portal_user_id=$1),true)`, draft.UserID).Scan(¬ify); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !notify {
|
||||
return nil, nil
|
||||
}
|
||||
return []string{draft.UserID}, nil
|
||||
case draft.UserID != "":
|
||||
return []string{draft.UserID}, nil
|
||||
case draft.RequestUser:
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestInboxPlanMapsEvents(t *testing.T) {
|
||||
wantUserID string
|
||||
wantAll bool
|
||||
wantRequest bool
|
||||
wantPref bool
|
||||
}{
|
||||
{name: "model_access.requested 通知全部管理员审批", eventType: "model_access.requested", values: map[string]any{"model": "gpt-5"}, wantKind: "admin", wantCategory: "approval", wantTitle: "新的模型访问申请", wantAll: true},
|
||||
{name: "model_access.decided 已批准回执给申请用户", eventType: "model_access.decided", values: map[string]any{"status": "approved"}, wantKind: "portal", wantCategory: "approval", wantTitle: "模型申请已处理", wantRequest: true},
|
||||
@@ -36,6 +37,7 @@ func TestInboxPlanMapsEvents(t *testing.T) {
|
||||
{name: "knowledge_document.embedding_failed 降级提示", eventType: "knowledge_document.embedding_failed", values: map[string]any{"actor_id": "22222222-2222-2222-2222-222222222222"}, wantKind: "admin", wantCategory: "system", wantTitle: "知识文档向量化失败", wantUserID: "22222222-2222-2222-2222-222222222222"},
|
||||
{name: "scheduled_task.completed 发给创建者", eventType: "scheduled_task.completed", values: map[string]any{"task_code": "daily-report", "actor_id": "33333333-3333-3333-3333-333333333333"}, wantKind: "admin", wantCategory: "task_result", wantTitle: "定时任务已执行", wantUserID: "33333333-3333-3333-3333-333333333333"},
|
||||
{name: "scheduled_task.failed 发给创建者", eventType: "scheduled_task.failed", values: map[string]any{"task_code": "daily-report", "error": "timeout", "actor_id": "33333333-3333-3333-3333-333333333333"}, wantKind: "admin", wantCategory: "task_result", wantTitle: "定时任务执行失败", wantUserID: "33333333-3333-3333-3333-333333333333"},
|
||||
{name: "security.login_detected 发登录提醒且受偏好约束", eventType: "security.login_detected", values: map[string]any{"portal_user_id": "44444444-4444-4444-4444-444444444444", "ip": "203.0.113.7"}, wantKind: "portal", wantCategory: "security", wantTitle: "新设备登录提醒", wantUserID: "44444444-4444-4444-4444-444444444444", wantPref: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -63,6 +65,9 @@ func TestInboxPlanMapsEvents(t *testing.T) {
|
||||
if draft.RequestUser != tc.wantRequest {
|
||||
t.Errorf("request_user = %v, want %v", draft.RequestUser, tc.wantRequest)
|
||||
}
|
||||
if draft.NotifyPref != tc.wantPref {
|
||||
t.Errorf("notify_pref = %v, want %v", draft.NotifyPref, tc.wantPref)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
-- 000038_portal_chat.sql — 门户通用聊天:会话、消息链(哈希完整性)与用户运行时凭据。
|
||||
-- 模型权限申请审批通过后,系统为该用户开通一把 gateway API Key(加密落库),
|
||||
-- 门户"通用聊天"页面用这把 Key 直接调用 /v1/chat/completions,用量与审计
|
||||
-- 均归属到用户自己的 Key,而不是共享一个全局凭据。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway.portal_user_runtime_credentials (
|
||||
portal_user_id uuid PRIMARY KEY REFERENCES gateway.portal_users(id) ON DELETE CASCADE,
|
||||
api_key_id uuid NOT NULL REFERENCES gateway.api_keys(id) ON DELETE CASCADE,
|
||||
encrypted_key bytea NOT NULL,
|
||||
key_kek_version integer NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE gateway.portal_user_runtime_credentials IS
|
||||
'Per-portal-user gateway credential used by the general chat. Plaintext is encrypted with the application KEK and never returned to a browser.';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway.portal_chat_sessions (
|
||||
id uuid PRIMARY KEY,
|
||||
portal_user_id uuid NOT NULL REFERENCES gateway.portal_users(id) ON DELETE CASCADE,
|
||||
title varchar(160) NOT NULL DEFAULT '',
|
||||
provider_code text NOT NULL DEFAULT '',
|
||||
model text NOT NULL CHECK (length(model) BETWEEN 1 AND 512),
|
||||
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
|
||||
busy boolean NOT NULL DEFAULT false,
|
||||
busy_token text,
|
||||
busy_since timestamptz,
|
||||
next_sequence integer NOT NULL DEFAULT 1 CHECK (next_sequence >= 1),
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS portal_chat_sessions_user_time_idx
|
||||
ON gateway.portal_chat_sessions (portal_user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway.portal_chat_messages (
|
||||
id uuid PRIMARY KEY,
|
||||
session_id uuid NOT NULL REFERENCES gateway.portal_chat_sessions(id) ON DELETE CASCADE,
|
||||
sequence integer NOT NULL,
|
||||
role varchar(16) NOT NULL CHECK (role IN ('user', 'assistant')),
|
||||
content text NOT NULL,
|
||||
previous_hash varchar(64) NOT NULL,
|
||||
message_hash varchar(64) NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
UNIQUE (session_id, sequence)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS portal_chat_messages_session_idx
|
||||
ON gateway.portal_chat_messages (session_id, sequence);
|
||||
@@ -0,0 +1,30 @@
|
||||
-- 000039_social_login.sql — 企微/钉钉/飞书扫码登录。
|
||||
-- 身份源表 identity_providers 扩展三种内置扫码登录 kind;企业用户与平台账号的
|
||||
-- 绑定关系单独落表(provider_uid 全局唯一,防止同一企微账号绑定多个本系统账号)。
|
||||
|
||||
ALTER TABLE gateway.identity_providers
|
||||
DROP CONSTRAINT IF EXISTS identity_providers_kind_check;
|
||||
|
||||
ALTER TABLE gateway.identity_providers
|
||||
ADD CONSTRAINT identity_providers_kind_check
|
||||
CHECK (kind IN ('oidc', 'saml', 'wecom', 'dingtalk', 'feishu'));
|
||||
|
||||
-- 扫码登录自动开通的账号 auth_source 记为平台 kind。
|
||||
ALTER TABLE gateway.portal_users
|
||||
DROP CONSTRAINT IF EXISTS portal_users_auth_source_check;
|
||||
|
||||
ALTER TABLE gateway.portal_users
|
||||
ADD CONSTRAINT portal_users_auth_source_check
|
||||
CHECK (auth_source IN ('local', 'feishu', 'oidc', 'saml', 'wecom', 'dingtalk'));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway.portal_user_provider_bindings (
|
||||
portal_user_id uuid NOT NULL REFERENCES gateway.portal_users(id) ON DELETE CASCADE,
|
||||
provider_kind varchar(16) NOT NULL CHECK (provider_kind IN ('wecom', 'dingtalk', 'feishu')),
|
||||
provider_uid varchar(255) NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
PRIMARY KEY (provider_kind, provider_uid),
|
||||
UNIQUE (portal_user_id, provider_kind)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS portal_user_provider_bindings_user_idx
|
||||
ON gateway.portal_user_provider_bindings (portal_user_id);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 000040_security_prefs.sql — 个人安全策略:新设备登录通知开关。
|
||||
-- 登录成功后 identity 服务发布 security.login_detected 事件,通知 worker 依据
|
||||
-- 本表开关决定是否落站内信(默认开启,收件人 = 账号本人)。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gateway.portal_security_prefs (
|
||||
portal_user_id uuid PRIMARY KEY REFERENCES gateway.portal_users(id) ON DELETE CASCADE,
|
||||
login_notify boolean NOT NULL DEFAULT true,
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 000041_inbox_security_category.sql — 站内信新增 security 类别(登录提醒等安全事件)。
|
||||
ALTER TABLE gateway.inbox_messages
|
||||
DROP CONSTRAINT IF EXISTS inbox_messages_category_check;
|
||||
|
||||
ALTER TABLE gateway.inbox_messages
|
||||
ADD CONSTRAINT inbox_messages_category_check
|
||||
CHECK (category = ANY (ARRAY['system', 'approval', 'task_result', 'resource', 'security']));
|
||||
@@ -160,3 +160,50 @@ export function createSAMLProvider(data: SAMLProviderInput) {
|
||||
export function updateSAMLProvider(id: string, data: SAMLProviderInput) {
|
||||
return request.put<SAMLProviderRecord>({ url: `/api/v1/admin/saml-providers/${id}`, params: data })
|
||||
}
|
||||
|
||||
export interface SocialProviderRecord {
|
||||
id: string
|
||||
code: string
|
||||
kind: 'wecom' | 'dingtalk' | 'feishu'
|
||||
display_name: string
|
||||
client_id: string
|
||||
agent_id: string
|
||||
secret_configured: boolean
|
||||
redirect_uri: string
|
||||
portal_return_url: string
|
||||
auto_provision: boolean
|
||||
default_department_id?: string
|
||||
enabled: boolean
|
||||
revision: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SocialProviderInput {
|
||||
code: string
|
||||
display_name: string
|
||||
client_id: string
|
||||
agent_id?: string
|
||||
secret?: string
|
||||
redirect_uri: string
|
||||
portal_return_url: string
|
||||
auto_provision: boolean
|
||||
default_department_id?: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function fetchSocialProviders() {
|
||||
return request.get<SocialProviderRecord[]>({ url: '/api/v1/admin/social-providers' })
|
||||
}
|
||||
|
||||
export function createSocialProvider(kind: string, data: SocialProviderInput) {
|
||||
return request.post<SocialProviderRecord>({ url: '/api/v1/admin/social-providers', params: { kind }, data })
|
||||
}
|
||||
|
||||
export function updateSocialProvider(kind: string, data: SocialProviderInput) {
|
||||
return request.put<SocialProviderRecord>({ url: `/api/v1/admin/social-providers/${kind}`, params: data })
|
||||
}
|
||||
|
||||
export function deleteSocialProvider(kind: string) {
|
||||
return request.del({ url: `/api/v1/admin/social-providers/${kind}` })
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<p class="text-g-500 mt-1 text-sm">管理员和门户账号统一管理,权限变更即时生效</p>
|
||||
</div>
|
||||
<ElButton type="primary" @click="openCreate">
|
||||
{{ activeTab === 'department' ? '新增部门' : activeTab === 'oidc' || activeTab === 'saml' ? '新增身份源' : '新增账号' }}
|
||||
{{ activeTab === 'department' ? '新增部门' : activeTab === 'oidc' || activeTab === 'saml' || activeTab === 'social' ? '新增身份源' : '新增账号' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<ElTabPane label="部门" name="department" />
|
||||
<ElTabPane label="OIDC 身份源" name="oidc" />
|
||||
<ElTabPane label="SAML 身份源" name="saml" />
|
||||
<ElTabPane label="扫码登录" name="social" />
|
||||
</ElTabs>
|
||||
|
||||
<ElTable v-if="activeTab === 'admin' || activeTab === 'portal'" v-loading="loading" :data="records" row-key="id">
|
||||
@@ -101,6 +102,31 @@
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElTable v-else-if="activeTab === 'social'" v-loading="loading" :data="socialProviders" row-key="kind">
|
||||
<ElTableColumn label="平台" width="110">
|
||||
<template #default="{ row }">{{ socialKindName(row.kind) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="code" label="代码" min-width="120" />
|
||||
<ElTableColumn prop="display_name" label="名称" min-width="140" />
|
||||
<ElTableColumn prop="client_id" label="AppID" min-width="180" show-overflow-tooltip />
|
||||
<ElTableColumn v-if="socialProviders.some((p: SocialProviderRecord) => p.kind === 'wecom')" prop="agent_id" label="AgentID" min-width="110" />
|
||||
<ElTableColumn label="密钥" width="80">
|
||||
<template #default="{ row }"><ElTag :type="row.secret_configured ? 'success' : 'danger'">{{ row.secret_configured ? '已配置' : '缺失' }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="自动开户" width="100">
|
||||
<template #default="{ row }">{{ row.auto_provision ? '启用' : '关闭' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="状态" width="90">
|
||||
<template #default="{ row }"><ElTag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton link type="primary" @click="openSocialProviderEdit(row)">编辑</ElButton>
|
||||
<ElButton link type="danger" @click="removeSocialProvider(row)">删除</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElTable v-else v-loading="loading" :data="samlProviders" row-key="id">
|
||||
<ElTableColumn prop="code" label="代码" min-width="130" />
|
||||
<ElTableColumn prop="display_name" label="名称" min-width="150" />
|
||||
@@ -257,22 +283,62 @@
|
||||
<ElButton type="primary" :loading="saving" @click="submitSAMLProvider">保存</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<ElDialog v-model="socialDialogVisible" :title="socialEditingKind ? '编辑扫码登录' : '新增扫码登录'" width="720px">
|
||||
<ElAlert class="mb-4" type="info" :closable="false" title="在企业微信/钉钉/飞书开放平台创建应用后填写;回调地址需配置为下方「回调 URL」。" />
|
||||
<ElForm :model="socialForm" label-width="150px">
|
||||
<ElFormItem label="平台" required>
|
||||
<ElSelect v-model="socialForm.kind" class="w-full" :disabled="!!socialEditingKind">
|
||||
<ElOption label="企业微信" value="wecom" />
|
||||
<ElOption label="钉钉" value="dingtalk" />
|
||||
<ElOption label="飞书" value="feishu" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="代码" required><ElInput v-model="socialForm.code" :placeholder="`例如 corp_${socialForm.kind || 'wecom'}`" :disabled="!!socialEditingKind" /></ElFormItem>
|
||||
<ElFormItem label="显示名称" required><ElInput v-model="socialForm.display_name" :placeholder="socialKindName(socialForm.kind)" /></ElFormItem>
|
||||
<ElFormItem v-if="socialForm.kind === 'wecom'" label="CorpID" required><ElInput v-model="socialForm.client_id" placeholder="企业微信 CorpID" /></ElFormItem>
|
||||
<ElFormItem v-else-if="socialForm.kind === 'dingtalk'" label="AppKey" required><ElInput v-model="socialForm.client_id" placeholder="钉钉应用 AppKey" /></ElFormItem>
|
||||
<ElFormItem v-else label="AppID" required><ElInput v-model="socialForm.client_id" placeholder="飞书应用 AppID" /></ElFormItem>
|
||||
<ElFormItem v-if="socialForm.kind === 'wecom'" label="AgentID" required><ElInput v-model="socialForm.agent_id" placeholder="企业微信应用 AgentID" /></ElFormItem>
|
||||
<ElFormItem label="AppSecret" :required="!socialEditingKind">
|
||||
<ElInput v-model="socialForm.secret" type="password" show-password :placeholder="socialEditingKind ? '留空则不修改' : '应用 AppSecret'" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="回调 URL" required>
|
||||
<ElInput v-model="socialForm.redirect_uri" :placeholder="`https://你的域名/api/v1/portal/sso/${socialForm.code || 'corp_wecom'}/callback`" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="门户返回 URL" required><ElInput v-model="socialForm.portal_return_url" placeholder="例如 https://你的域名/#/auth/login" /></ElFormItem>
|
||||
<ElFormItem label="默认部门">
|
||||
<ElSelect v-model="socialForm.default_department_id" clearable class="w-full">
|
||||
<ElOption v-for="department in activeDepartments" :key="department.id" :label="department.name" :value="department.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="自动开户"><ElSwitch v-model="socialForm.auto_provision" /></ElFormItem>
|
||||
<ElFormItem label="启用"><ElSwitch v-model="socialForm.enabled" /></ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="socialDialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="saving" @click="submitSocialProvider">保存</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, FormInstance, FormRules, TabsPaneContext } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules, TabsPaneContext } from 'element-plus'
|
||||
import {
|
||||
createDepartment,
|
||||
createIdentityProvider,
|
||||
createSAMLProvider,
|
||||
createIdentity,
|
||||
createSocialProvider,
|
||||
deleteSocialProvider,
|
||||
DepartmentInput,
|
||||
DepartmentRecord,
|
||||
fetchDepartments,
|
||||
fetchIdentityProviders,
|
||||
fetchSAMLProviders,
|
||||
fetchIdentities,
|
||||
fetchSocialProviders,
|
||||
IdentityInput,
|
||||
IdentityKind,
|
||||
IdentityRecord,
|
||||
@@ -280,10 +346,13 @@
|
||||
IdentityProviderRecord,
|
||||
SAMLProviderInput,
|
||||
SAMLProviderRecord,
|
||||
SocialProviderInput,
|
||||
SocialProviderRecord,
|
||||
updateDepartment,
|
||||
updateIdentityProvider,
|
||||
updateSAMLProvider,
|
||||
updateIdentity
|
||||
updateIdentity,
|
||||
updateSocialProvider
|
||||
} from '@/api/identities'
|
||||
|
||||
defineOptions({ name: 'User' })
|
||||
@@ -295,7 +364,7 @@
|
||||
'api_key:read',
|
||||
'api_key:manage'
|
||||
]
|
||||
type ManagementTab = IdentityKind | 'department' | 'oidc' | 'saml'
|
||||
type ManagementTab = IdentityKind | 'department' | 'oidc' | 'saml' | 'social'
|
||||
const activeTab = ref<ManagementTab>('admin')
|
||||
const records = ref<IdentityRecord[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -305,12 +374,15 @@
|
||||
const departments = ref<DepartmentRecord[]>([])
|
||||
const identityProviders = ref<IdentityProviderRecord[]>([])
|
||||
const samlProviders = ref<SAMLProviderRecord[]>([])
|
||||
const socialProviders = ref<SocialProviderRecord[]>([])
|
||||
const departmentDialogVisible = ref(false)
|
||||
const departmentEditingId = ref('')
|
||||
const idpDialogVisible = ref(false)
|
||||
const idpEditingId = ref('')
|
||||
const samlDialogVisible = ref(false)
|
||||
const samlEditingId = ref('')
|
||||
const socialDialogVisible = ref(false)
|
||||
const socialEditingKind = ref('')
|
||||
const formRef = ref<FormInstance>()
|
||||
const departmentFormRef = ref<FormInstance>()
|
||||
const form = reactive<IdentityInput>({
|
||||
@@ -335,6 +407,11 @@
|
||||
portal_return_url: '', email_attribute: 'mail', name_attribute: 'cn',
|
||||
auto_provision: false, default_department_id: undefined, enabled: false
|
||||
})
|
||||
const socialForm = reactive<SocialProviderInput & { kind: string }>({
|
||||
kind: 'wecom', code: '', display_name: '', client_id: '', agent_id: '',
|
||||
secret: '', redirect_uri: '', portal_return_url: '',
|
||||
auto_provision: false, default_department_id: undefined, enabled: false
|
||||
})
|
||||
const activeDepartments = computed(() => departments.value.filter((item) => item.active))
|
||||
const availableParents = computed(() =>
|
||||
activeDepartments.value.filter((item) => item.id !== departmentEditingId.value)
|
||||
@@ -363,6 +440,7 @@
|
||||
if (activeTab.value === 'admin' || activeTab.value === 'portal') records.value = await fetchIdentities(activeTab.value)
|
||||
if (activeTab.value === 'oidc') identityProviders.value = await fetchIdentityProviders()
|
||||
if (activeTab.value === 'saml') samlProviders.value = await fetchSAMLProviders()
|
||||
if (activeTab.value === 'social') socialProviders.value = await fetchSocialProviders()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -399,6 +477,12 @@
|
||||
samlDialogVisible.value = true
|
||||
return
|
||||
}
|
||||
if (activeTab.value === 'social') {
|
||||
socialEditingKind.value = ''
|
||||
Object.assign(socialForm, { kind: 'wecom', code: '', display_name: '', client_id: '', agent_id: '', secret: '', redirect_uri: '', portal_return_url: '', auto_provision: false, default_department_id: undefined, enabled: false })
|
||||
socialDialogVisible.value = true
|
||||
return
|
||||
}
|
||||
editingId.value = ''
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
@@ -499,6 +583,55 @@
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
function socialKindName(kind: string) {
|
||||
return ({ wecom: '企业微信', dingtalk: '钉钉', feishu: '飞书' } as Record<string, string>)[kind] || kind
|
||||
}
|
||||
|
||||
function openSocialProviderEdit(record: SocialProviderRecord) {
|
||||
socialEditingKind.value = record.kind
|
||||
Object.assign(socialForm, {
|
||||
kind: record.kind, code: record.code, display_name: record.display_name,
|
||||
client_id: record.client_id, agent_id: record.agent_id || '',
|
||||
secret: '', redirect_uri: record.redirect_uri, portal_return_url: record.portal_return_url,
|
||||
auto_provision: record.auto_provision, default_department_id: record.default_department_id, enabled: record.enabled
|
||||
})
|
||||
socialDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitSocialProvider() {
|
||||
const form = socialForm
|
||||
if (!form.code || !form.display_name || !form.client_id || !form.redirect_uri || !form.portal_return_url || (form.kind === 'wecom' && !form.agent_id)) {
|
||||
ElMessage.warning('请填写所有必填扫码登录配置')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: SocialProviderInput = {
|
||||
code: form.code, display_name: form.display_name, client_id: form.client_id,
|
||||
agent_id: form.agent_id, secret: form.secret || undefined,
|
||||
redirect_uri: form.redirect_uri, portal_return_url: form.portal_return_url,
|
||||
auto_provision: form.auto_provision, default_department_id: form.default_department_id, enabled: form.enabled
|
||||
}
|
||||
if (!payload.default_department_id) delete payload.default_department_id
|
||||
if (socialEditingKind.value) {
|
||||
if (!payload.secret) delete payload.secret
|
||||
await updateSocialProvider(socialEditingKind.value, payload)
|
||||
} else {
|
||||
await createSocialProvider(form.kind, payload)
|
||||
}
|
||||
ElMessage.success('扫码登录身份源保存成功')
|
||||
socialDialogVisible.value = false
|
||||
await load()
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function removeSocialProvider(record: SocialProviderRecord) {
|
||||
await ElMessageBox.confirm(`删除后该平台的所有扫码绑定将失效,确定删除 ${socialKindName(record.kind)} 身份源?`, '删除身份源', { type: 'warning' })
|
||||
await deleteSocialProvider(record.kind)
|
||||
socialProviders.value = socialProviders.value.filter((item) => item.kind !== record.kind)
|
||||
ElMessage.success('已删除')
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!(await formRef.value?.validate())) return
|
||||
if (!editingId.value && (!form.password || form.password.length < 12)) {
|
||||
|
||||
@@ -65,3 +65,27 @@ export const fetchMarketplaceInstalled=()=>request.get<MarketItem[]>({url:'/api/
|
||||
export const fetchMarketplaceDetail=(type:string,code:string)=>request.get<{item:MarketItem;detail:Record<string,unknown>}>({url:`/api/v1/portal/marketplace/${type}/${code}`})
|
||||
export const marketplaceInstall=(type:string,code:string)=>request.post<{installed:boolean;created:boolean}>({url:`/api/v1/portal/marketplace/${type}/${code}/install`})
|
||||
export const marketplaceUninstall=(type:string,code:string)=>request.del<{installed:boolean}>({url:`/api/v1/portal/marketplace/${type}/${code}/install`})
|
||||
|
||||
// --- 通用聊天 ---
|
||||
export interface ChatModel { provider_code:string;model:string;approved_at:string }
|
||||
export interface ChatMessage { sequence:number;role:'user'|'assistant';content:string;created_at:string }
|
||||
export interface ChatSession { id:string;title:string;provider_code:string;model:string;status:string;messages?:ChatMessage[];created_at:string;updated_at:string }
|
||||
export const fetchChatModels=()=>request.get<ChatModel[]>({url:'/api/v1/portal/chat/models'})
|
||||
export const chatOnce=(params:{provider_code:string;model:string;message:string})=>request.post<Record<string,unknown>>({url:'/api/v1/portal/chat/completions',params})
|
||||
export const fetchChatSessions=()=>request.get<ChatSession[]>({url:'/api/v1/portal/chat/sessions'})
|
||||
export const createChatSession=(params:{provider_code:string;model:string})=>request.post<ChatSession>({url:'/api/v1/portal/chat/sessions',params})
|
||||
export const renameChatSession=(id:string,title:string)=>request.put<ChatSession>({url:`/api/v1/portal/chat/sessions/${id}`,params:{title}})
|
||||
export const deleteChatSession=(id:string)=>request.del({url:`/api/v1/portal/chat/sessions/${id}`})
|
||||
export const fetchChatSession=(id:string)=>request.get<ChatSession>({url:`/api/v1/portal/chat/sessions/${id}`})
|
||||
export const appendChatMessage=(id:string,message:string)=>request.post<Record<string,unknown>>({url:`/api/v1/portal/chat/sessions/${id}/messages`,params:{message}})
|
||||
|
||||
// --- 账号安全 ---
|
||||
export interface SessionView { id:string;ip:string;user_agent:string;issued_at:number;current:boolean }
|
||||
export interface ProviderBinding { kind:'wecom'|'dingtalk'|'feishu';provider_uid:string;created_at:string }
|
||||
export const fetchMySessions=()=>request.get<SessionView[]>({url:'/api/v1/portal/sessions'})
|
||||
export const revokeSession=(id:string)=>request.post({url:`/api/v1/portal/sessions/${id}/revoke`})
|
||||
export const fetchSecurityPrefs=()=>request.get<{login_notify:boolean}>({url:'/api/v1/portal/security/prefs'})
|
||||
export const setSecurityPrefs=(login_notify:boolean)=>request.put<{login_notify:boolean}>({url:'/api/v1/portal/security/prefs',params:{login_notify}})
|
||||
export const fetchProviderBindings=()=>request.get<ProviderBinding[]>({url:'/api/v1/portal/social/bindings'})
|
||||
export const startSocialBind=(kind:string)=>request.post<{redirect_url:string}>({url:`/api/v1/portal/social/${kind}/bind/start`})
|
||||
export const unbindSocial=(kind:string)=>request.del({url:`/api/v1/portal/social/${kind}/bind`})
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { HttpError } from '@/utils/http/error'
|
||||
import { exchangeSSOCode, fetchLogin, fetchSSOProviders, fetchTOTPLogin, SSOProvider } from '@/api/auth'
|
||||
import { ElMessageBox, ElNotification, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox, ElNotification, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
defineOptions({ name: 'Login' })
|
||||
|
||||
@@ -196,14 +196,21 @@
|
||||
}
|
||||
try {
|
||||
ssoProviders.value = await fetchSSOProviders()
|
||||
const ssoCode = route.query.sso_code as string
|
||||
// 回调 302 把参数放在 # 之前的 query(SPA hash 路由看不到),这里合并读取。
|
||||
const pathQuery = new URLSearchParams(window.location.search)
|
||||
const ssoCode = (route.query.sso_code as string) || pathQuery.get('sso_code') || ''
|
||||
const ssoError = (route.query.sso_error as string) || pathQuery.get('sso_error') || ''
|
||||
if (ssoCode) {
|
||||
loading.value = true
|
||||
const result = await exchangeSSOCode(ssoCode)
|
||||
if (!result.token) throw new Error('SSO exchange returned no token')
|
||||
userStore.setToken(result.token, result.refreshToken || '')
|
||||
userStore.setLoginStatus(true)
|
||||
history.replaceState(null, '', window.location.pathname + window.location.hash)
|
||||
await router.replace('/')
|
||||
} else if (ssoError) {
|
||||
ElMessage.error(ssoError)
|
||||
history.replaceState(null, '', window.location.pathname + window.location.hash)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">通用聊天</h2>
|
||||
<p class="text-g-500 mt-1 text-sm">直接与已获批准接入的模型对话,用量与审计归属到你的账号;新模型请先在「模型权限」申请</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<ElSelect v-model="selectedModel" placeholder="选择模型" class="w-72" filterable @change="onModelChange">
|
||||
<ElOptionGroup v-for="group in modelGroups" :key="group.provider" :label="group.provider">
|
||||
<ElOption v-for="m in group.models" :key="m.model" :label="m.model" :value="`${m.provider_code}\n${m.model}`" />
|
||||
</ElOptionGroup>
|
||||
</ElSelect>
|
||||
<ElButton type="primary" :disabled="!selectedModel" @click="newChat">新会话</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElEmpty v-if="!loading && !models.length" description="暂无已批准的模型,请先在「模型权限」页申请">
|
||||
<ElButton type="primary" @click="$router.push('/portal/access')">去申请</ElButton>
|
||||
</ElEmpty>
|
||||
|
||||
<div v-else class="flex gap-4" style="height: calc(100vh - 220px)">
|
||||
<!-- 会话列表 -->
|
||||
<div class="w-64 shrink-0 overflow-auto rounded-lg border border-g-200 bg-white">
|
||||
<div class="border-b border-g-100 px-3 py-2 text-sm font-medium text-g-500">会话历史</div>
|
||||
<div
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
class="group cursor-pointer border-b border-g-100 px-3 py-2 hover:bg-g-50"
|
||||
:class="currentId === session.id ? 'bg-primary-50' : ''"
|
||||
@click="openSession(session)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm">{{ session.title || session.model }}</div>
|
||||
<div class="text-g-400 mt-0.5 truncate text-xs">{{ session.model }}</div>
|
||||
</div>
|
||||
<div class="hidden shrink-0 gap-1 group-hover:flex">
|
||||
<ElButton link size="small" @click.stop="rename(session)">改名</ElButton>
|
||||
<ElButton link size="small" type="danger" @click.stop="remove(session)">删除</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-if="!sessions.length" description="暂无会话" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<!-- 对话区 -->
|
||||
<div class="flex min-w-0 flex-1 flex-col rounded-lg border border-g-200 bg-white">
|
||||
<div class="flex-1 space-y-4 overflow-auto p-4" ref="scrollRef">
|
||||
<ElEmpty v-if="!messages.length" description="开始你的第一轮对话吧" :image-size="80" />
|
||||
<div v-for="message in messages" :key="message.sequence" class="flex" :class="message.role === 'user' ? 'justify-end' : 'justify-start'">
|
||||
<div
|
||||
class="max-w-[80%] whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-sm"
|
||||
:class="message.role === 'user' ? 'bg-primary-600 text-white' : 'bg-g-100 text-g-800'"
|
||||
>{{ message.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-g-100 p-3">
|
||||
<ElInput
|
||||
v-model="draft"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="100000"
|
||||
resize="none"
|
||||
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
/>
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<span class="text-g-400 text-xs">{{ sending ? '模型思考中…' : `${draft.length} / 100000` }}</span>
|
||||
<ElButton type="primary" :loading="sending" :disabled="!draft.trim()" @click="send">发送</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ChatMessage, ChatModel, ChatSession,
|
||||
appendChatMessage, createChatSession, deleteChatSession, fetchChatModels,
|
||||
fetchChatSession, fetchChatSessions, renameChatSession
|
||||
} from '@/api/portal'
|
||||
|
||||
const loading = ref(false)
|
||||
const sending = ref(false)
|
||||
const models = ref<ChatModel[]>([])
|
||||
const sessions = ref<ChatSession[]>([])
|
||||
const currentId = ref('')
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const draft = ref('')
|
||||
const selectedModel = ref('')
|
||||
const scrollRef = ref<HTMLElement>()
|
||||
|
||||
const modelGroups = computed(() => {
|
||||
const groups: { provider: string; models: ChatModel[] }[] = []
|
||||
const index = new Map<string, ChatModel[]>()
|
||||
for (const m of models.value) {
|
||||
if (!index.has(m.provider_code)) index.set(m.provider_code, [])
|
||||
index.get(m.provider_code)!.push(m)
|
||||
}
|
||||
for (const [provider, list] of index) groups.push({ provider, models: list })
|
||||
return groups
|
||||
})
|
||||
|
||||
function modelOf(value: string): { provider_code: string; model: string } {
|
||||
const [provider_code, model] = value.split('\n')
|
||||
return { provider_code: provider_code || '', model: model || '' }
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [modelList, sessionList] = await Promise.all([fetchChatModels(), fetchChatSessions()])
|
||||
models.value = modelList
|
||||
sessions.value = sessionList
|
||||
if (models.value.length && !selectedModel.value) {
|
||||
selectedModel.value = `${models.value[0].provider_code}\n${models.value[0].model}`
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onModelChange() {
|
||||
currentId.value = ''
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => scrollRef.value?.scrollTo({ top: scrollRef.value.scrollHeight }))
|
||||
}
|
||||
|
||||
async function newChat() {
|
||||
if (!selectedModel.value) return
|
||||
const { provider_code, model } = modelOf(selectedModel.value)
|
||||
sending.value = true
|
||||
try {
|
||||
const session = await createChatSession({ provider_code, model })
|
||||
sessions.value.unshift(session)
|
||||
currentId.value = session.id
|
||||
messages.value = []
|
||||
draft.value = ''
|
||||
ElMessage.success('已创建新会话')
|
||||
} catch (error) {
|
||||
ElMessage.error('创建会话失败,请确认模型已批准且可用')
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openSession(session: ChatSession) {
|
||||
currentId.value = session.id
|
||||
const detail = await fetchChatSession(session.id)
|
||||
messages.value = detail.messages || []
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = draft.value.trim()
|
||||
if (!text || sending.value) return
|
||||
if (!currentId.value) {
|
||||
if (!selectedModel.value) {
|
||||
ElMessage.warning('请先选择模型')
|
||||
return
|
||||
}
|
||||
await newChat()
|
||||
if (!currentId.value) return
|
||||
}
|
||||
sending.value = true
|
||||
const id = currentId.value
|
||||
messages.value.push({ sequence: messages.value.length + 1, role: 'user', content: text, created_at: '' })
|
||||
draft.value = ''
|
||||
scrollToBottom()
|
||||
try {
|
||||
const response = await appendChatMessage(id, text)
|
||||
const choices = (response.choices as Array<{ message?: { content?: string } }>) || []
|
||||
const answer = choices[0]?.message?.content || ''
|
||||
messages.value.push({ sequence: messages.value.length + 1, role: 'assistant', content: answer, created_at: '' })
|
||||
const session = sessions.value.find((item) => item.id === id)
|
||||
if (session && !session.title) session.title = text.slice(0, 60)
|
||||
} catch (error) {
|
||||
const message = (error as Error)?.message || '调用失败'
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
sending.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
async function rename(session: ChatSession) {
|
||||
const { value } = await ElMessageBox.prompt('输入新的会话标题', '重命名会话', { inputValue: session.title || session.model, inputValidator: (v: string) => (v.trim() ? true : '标题不能为空') })
|
||||
if (value) {
|
||||
await renameChatSession(session.id, value.trim())
|
||||
session.title = value.trim()
|
||||
ElMessage.success('已重命名')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(session: ChatSession) {
|
||||
await ElMessageBox.confirm('删除后会话记录不可恢复,确定删除?', '删除会话', { type: 'warning' })
|
||||
await deleteChatSession(session.id)
|
||||
sessions.value = sessions.value.filter((item) => item.id !== session.id)
|
||||
if (currentId.value === session.id) {
|
||||
currentId.value = ''
|
||||
messages.value = []
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
}
|
||||
|
||||
onMounted(loadAll)
|
||||
</script>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5">
|
||||
<h2 class="text-xl font-semibold">账号安全</h2>
|
||||
<p class="text-g-500 mt-1 text-sm">管理登录设备、新设备登录提醒与扫码登录绑定</p>
|
||||
</div>
|
||||
|
||||
<ElCard shadow="never" class="mb-5">
|
||||
<template #header><div class="font-medium">登录设备</div></template>
|
||||
<ElTable v-loading="loading" :data="sessions">
|
||||
<ElTableColumn label="当前" width="80">
|
||||
<template #default="{ row }"><ElTag v-if="row.current" type="success">当前</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="ip" label="IP 地址" width="160" />
|
||||
<ElTableColumn prop="user_agent" label="设备 / 浏览器" min-width="240">
|
||||
<template #default="{ row }">{{ row.user_agent || '未知设备' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="登录时间" width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.issued_at) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton link type="danger" :disabled="row.current" @click="revoke(row)">下线</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
<ElEmpty v-if="!sessions.length && !loading" description="暂无其他登录设备" />
|
||||
</ElCard>
|
||||
|
||||
<ElCard shadow="never" class="mb-5">
|
||||
<template #header><div class="font-medium">登录提醒</div></template>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm">新设备登录通知</div>
|
||||
<p class="text-g-500 mt-1 text-sm">账号在新设备登录时通过站内消息提醒,可及时发现异常登录</p>
|
||||
</div>
|
||||
<ElSwitch v-model="loginNotify" :loading="savingPrefs" @change="savePrefs" />
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard shadow="never">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-medium">扫码登录绑定</div>
|
||||
<span class="text-g-400 text-xs">绑定后可用企业微信 / 钉钉 / 飞书扫码直接登录</span>
|
||||
</div>
|
||||
</template>
|
||||
<ElTable v-loading="loading" :data="bindingRows">
|
||||
<ElTableColumn label="平台" width="140">
|
||||
<template #default="{ row }">{{ providerName(row.kind) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="provider_uid" label="企业账号" min-width="200" />
|
||||
<ElTableColumn label="绑定时间" width="180">
|
||||
<template #default="{ row }">{{ row.created_at }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.bound">
|
||||
<ElButton link type="primary" @click="reBind(row.kind)">重新绑定</ElButton>
|
||||
<ElButton link type="danger" @click="unbind(row.kind)">解绑</ElButton>
|
||||
</template>
|
||||
<ElButton v-else link type="primary" @click="bind(row.kind)">绑定</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
<p class="text-g-400 mt-3 text-xs">绑定需使用平台 App 扫码授权;同一平台账号只能绑定到一个本系统账号。未配置的身份源请在管理端「系统管理 → 身份源」中启用。</p>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ProviderBinding, SessionView, fetchMySessions, fetchProviderBindings, fetchSecurityPrefs,
|
||||
revokeSession, setSecurityPrefs, startSocialBind, unbindSocial
|
||||
} from '@/api/portal'
|
||||
|
||||
const loading = ref(false)
|
||||
const savingPrefs = ref(false)
|
||||
const sessions = ref<SessionView[]>([])
|
||||
const loginNotify = ref(true)
|
||||
const bindings = ref<ProviderBinding[]>([])
|
||||
|
||||
const providerKinds = ['wecom', 'dingtalk', 'feishu']
|
||||
const providerNames: Record<string, string> = { wecom: '企业微信', dingtalk: '钉钉', feishu: '飞书' }
|
||||
|
||||
const bindingRows = computed(() =>
|
||||
providerKinds.map((kind) => {
|
||||
const item = bindings.value.find((b) => b.kind === kind)
|
||||
return { kind, bound: !!item, provider_uid: item?.provider_uid || '—', created_at: item?.created_at || '—' }
|
||||
})
|
||||
)
|
||||
|
||||
function providerName(kind: string) {
|
||||
return providerNames[kind] || kind
|
||||
}
|
||||
|
||||
function formatTime(epochSeconds: number) {
|
||||
if (!epochSeconds) return '—'
|
||||
return new Date(epochSeconds * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [sessionList, pref, bindingList] = await Promise.all([fetchMySessions(), fetchSecurityPrefs(), fetchProviderBindings()])
|
||||
sessions.value = sessionList
|
||||
loginNotify.value = pref.login_notify
|
||||
bindings.value = bindingList
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(row: SessionView) {
|
||||
await ElMessageBox.confirm('下线该设备后,其登录状态将立即失效。确定下线?', '下线设备', { type: 'warning' })
|
||||
await revokeSession(row.id)
|
||||
sessions.value = sessions.value.filter((item) => item.id !== row.id)
|
||||
ElMessage.success('已下线')
|
||||
}
|
||||
|
||||
async function savePrefs(value: string | number | boolean) {
|
||||
const enabled = value === true || value === 'true' || value === 1
|
||||
savingPrefs.value = true
|
||||
try {
|
||||
await setSecurityPrefs(enabled)
|
||||
ElMessage.success(enabled ? '已开启登录提醒' : '已关闭登录提醒')
|
||||
} catch {
|
||||
loginNotify.value = !enabled
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
savingPrefs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function bind(kind: string) {
|
||||
const { redirect_url } = await startSocialBind(kind)
|
||||
const win = window.open(redirect_url, '_blank', 'width=720,height=600')
|
||||
if (win) {
|
||||
// 扫码完成后回调窗口会带 bind_result 跳回门户;本窗口聚焦时刷新绑定状态。
|
||||
const timer = window.setInterval(async () => {
|
||||
if (win.closed) {
|
||||
window.clearInterval(timer)
|
||||
await loadAll()
|
||||
}
|
||||
}, 1000)
|
||||
window.addEventListener('focus', () => {
|
||||
window.clearInterval(timer)
|
||||
loadAll()
|
||||
}, { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function reBind(kind: string) {
|
||||
await bind(kind)
|
||||
}
|
||||
|
||||
async function unbind(kind: string) {
|
||||
await ElMessageBox.confirm(`解绑后该平台将无法扫码登录此账号,确定解绑?`, '解除绑定', { type: 'warning' })
|
||||
await unbindSocial(kind)
|
||||
await loadAll()
|
||||
ElMessage.success('已解绑')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
// 扫码绑定回调 302 回门户时携带 bind_result(位于 # 之前的 query)。
|
||||
const pathQuery = new URLSearchParams(window.location.search)
|
||||
const result = pathQuery.get('bind_result')
|
||||
if (result) {
|
||||
history.replaceState(null, '', window.location.pathname + window.location.hash)
|
||||
if (result === 'ok') ElMessage.success('扫码登录绑定成功')
|
||||
else if (result === 'conflict') ElMessage.error('该企业账号已被其他账号绑定')
|
||||
else ElMessage.error('扫码绑定失败或已取消')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user