diff --git a/apps/admin-web/src/components/CustomArticleTab.vue b/apps/admin-web/src/components/CustomArticleTab.vue index 253234c..c3be0fb 100644 --- a/apps/admin-web/src/components/CustomArticleTab.vue +++ b/apps/admin-web/src/components/CustomArticleTab.vue @@ -57,7 +57,8 @@ const publishModalOpen = ref(false) const selectedPublishArticleId = ref(null) const rulesQuery = useQuery({ - queryKey: ['promptRules', 'simple'], + queryKey: computed(() => ['promptRules', 'simple', companyStore.currentBrandId]), + enabled: computed(() => Boolean(companyStore.currentBrandId)), queryFn: () => promptRulesApi.listSimple(), }) @@ -246,6 +247,15 @@ watch( { immediate: true }, ) +watch( + () => companyStore.currentBrandId, + () => { + draftFilters.prompt_rule_id = undefined + appliedFilters.prompt_rule_id = undefined + page.value = 1 + }, +) + onBeforeUnmount(() => { stopPolling() }) diff --git a/apps/admin-web/src/components/GenerateTaskDrawer.vue b/apps/admin-web/src/components/GenerateTaskDrawer.vue index 198c795..1557393 100644 --- a/apps/admin-web/src/components/GenerateTaskDrawer.vue +++ b/apps/admin-web/src/components/GenerateTaskDrawer.vue @@ -59,9 +59,11 @@ const form = reactive({ }) const isSchedule = computed(() => props.mode === 'schedule') +const currentBrandId = computed(() => companyStore.currentBrandId) const rulesQuery = useQuery({ - queryKey: ['promptRules', 'simple'], + queryKey: computed(() => ['promptRules', 'simple', currentBrandId.value]), + enabled: computed(() => props.open && Boolean(currentBrandId.value)), queryFn: () => promptRulesApi.listSimple(), }) @@ -110,7 +112,6 @@ const selectedSchedulePublishPlatformIds = computed(() => { const scheduleCoverRequired = computed(() => coverUploadRequired(selectedSchedulePublishPlatformIds.value), ) -const currentBrandId = computed(() => companyStore.currentBrandId) const effectiveScheduleCoverEnabled = computed( () => scheduleCoverRequired.value || form.coverEnabled, ) @@ -148,6 +149,10 @@ watch( }, ) +watch(currentBrandId, () => { + form.promptRuleId = undefined +}) + const createScheduleMutation = useMutation({ mutationFn: () => schedulesApi.create(buildSchedulePayload()), onSuccess: async () => { diff --git a/apps/admin-web/src/components/InstantTaskTab.vue b/apps/admin-web/src/components/InstantTaskTab.vue index e782d49..942c856 100644 --- a/apps/admin-web/src/components/InstantTaskTab.vue +++ b/apps/admin-web/src/components/InstantTaskTab.vue @@ -41,7 +41,8 @@ const appliedFilters = reactive<{ }) const rulesQuery = useQuery({ - queryKey: ['promptRules', 'simple'], + queryKey: computed(() => ['promptRules', 'simple', companyStore.currentBrandId]), + enabled: computed(() => Boolean(companyStore.currentBrandId)), queryFn: () => promptRulesApi.listSimple(), }) @@ -175,6 +176,15 @@ watch( { immediate: true }, ) +watch( + () => companyStore.currentBrandId, + () => { + draftFilters.prompt_rule_id = undefined + appliedFilters.prompt_rule_id = undefined + page.value = 1 + }, +) + onBeforeUnmount(() => { stopPolling() }) diff --git a/apps/admin-web/src/components/PromptRuleModal.vue b/apps/admin-web/src/components/PromptRuleModal.vue index f15e66e..eea902d 100644 --- a/apps/admin-web/src/components/PromptRuleModal.vue +++ b/apps/admin-web/src/components/PromptRuleModal.vue @@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n' import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue' import { promptRulesApi } from '@/lib/api' import { formatError } from '@/lib/errors' +import { useCompanyStore } from '@/stores/company' const props = defineProps<{ open: boolean @@ -22,6 +23,7 @@ const emit = defineEmits<{ const queryClient = useQueryClient() const { t } = useI18n() +const companyStore = useCompanyStore() const form = reactive({ name: '', @@ -34,7 +36,8 @@ const form = reactive({ }) const groupsQuery = useQuery({ - queryKey: ['promptRules', 'groups'], + queryKey: computed(() => ['promptRules', 'groups', companyStore.currentBrandId]), + enabled: computed(() => props.open && Boolean(companyStore.currentBrandId)), queryFn: () => promptRulesApi.listGroups(), }) @@ -56,6 +59,15 @@ watch( }, ) +watch( + () => companyStore.currentBrandId, + () => { + if (props.open) { + emit('update:open', false) + } + }, +) + const createMutation = useMutation({ mutationFn: () => promptRulesApi.create({ diff --git a/apps/admin-web/src/components/PromptRuleTab.vue b/apps/admin-web/src/components/PromptRuleTab.vue index e2ee903..4c812da 100644 --- a/apps/admin-web/src/components/PromptRuleTab.vue +++ b/apps/admin-web/src/components/PromptRuleTab.vue @@ -16,9 +16,11 @@ import PromptRuleModal from '@/components/PromptRuleModal.vue' import { promptRulesApi } from '@/lib/api' import { formatDateTime } from '@/lib/display' import { formatError } from '@/lib/errors' +import { useCompanyStore } from '@/stores/company' const queryClient = useQueryClient() const { t } = useI18n() +const companyStore = useCompanyStore() const selectedGroupId = ref('all') const ruleModalOpen = ref(false) @@ -30,7 +32,8 @@ const page = ref(1) const pageSize = ref(20) const groupsQuery = useQuery({ - queryKey: ['promptRules', 'groups'], + queryKey: computed(() => ['promptRules', 'groups', companyStore.currentBrandId]), + enabled: computed(() => Boolean(companyStore.currentBrandId)), queryFn: () => promptRulesApi.listGroups(), }) @@ -48,7 +51,8 @@ const ruleListParams = computed(() => { }) const rulesQuery = useQuery({ - queryKey: computed(() => ['promptRules', 'list', ruleListParams.value]), + queryKey: computed(() => ['promptRules', 'list', companyStore.currentBrandId, ruleListParams.value]), + enabled: computed(() => Boolean(companyStore.currentBrandId)), queryFn: () => promptRulesApi.list(ruleListParams.value), }) @@ -169,6 +173,17 @@ function handleTableChange(nextPage: number, nextPageSize: number): void { watch(selectedGroupId, () => { page.value = 1 }) + +watch( + () => companyStore.currentBrandId, + () => { + selectedGroupId.value = 'all' + editingRule.value = null + ruleModalOpen.value = false + groupModalOpen.value = false + page.value = 1 + }, +) @@ -1337,12 +1451,12 @@ onBeforeUnmount(() => { margin-bottom: 4px; } -.user-dropdown-logout { - color: #ff4d4f; +.user-dropdown-item-icon { + margin-right: 8px; } -.user-dropdown-logout-icon { - margin-right: 8px; +.user-dropdown-logout { + color: #ff4d4f; } .admin-content { diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 8468ad0..1be5b8c 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -11,6 +11,7 @@ import type { Brand, BrandLibrarySummary, BrandRequest, + ChangePasswordRequest, Competitor, CompetitorRequest, ComplianceAckRecord, @@ -322,6 +323,7 @@ const currentBrandScopedPathPatterns = [ /^\/api\/tenant\/instant-tasks(?:\/|$)/, /^\/api\/tenant\/publish-jobs(?:\/|$)/, /^\/api\/tenant\/publish-tasks\/[^/]+\/retry(?:\/|$)/, + /^\/api\/tenant\/prompt-rules(?:\/|$)/, ] function shouldAttachCurrentBrandHeader(url: unknown): boolean { @@ -406,6 +408,9 @@ export const authApi = { me() { return apiClient.get('/api/auth/me') }, + changePassword(payload: ChangePasswordRequest) { + return apiClient.post<{ ok: boolean }, ChangePasswordRequest>('/api/auth/password', payload) + }, logout() { return apiClient.post('/api/auth/logout') }, diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index 00e5e98..63016d8 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -15,6 +15,8 @@ const authSessionErrorMessages = new Set([ const errorMessageMap: Record = { invalid_credentials: '邮箱或密码错误', + invalid_old_password: '原密码错误', + weak_password: '新密码至少 8 位', login_rate_limited: '登录请求过于频繁', login_locked: '登录已被临时保护', login_in_progress: '登录请求正在处理中', diff --git a/apps/admin-web/src/lib/password-cipher.ts b/apps/admin-web/src/lib/password-cipher.ts index 2cfcf81..390e4f3 100644 --- a/apps/admin-web/src/lib/password-cipher.ts +++ b/apps/admin-web/src/lib/password-cipher.ts @@ -10,16 +10,23 @@ export async function encryptPasswordForLogin( password: string, key: PasswordPublicKeyResponse, ): Promise<{ encrypted_password: string; password_key_id: string }> { + return { + encrypted_password: await encryptPassword(password, key), + password_key_id: key.key_id, + } +} + +export async function encryptPassword( + password: string, + key: PasswordPublicKeyResponse, +): Promise { const cryptoKey = await importRSAOAEPKey(key.public_key) const encrypted = await window.crypto.subtle.encrypt( { name: 'RSA-OAEP' }, cryptoKey, textEncoder.encode(password), ) - return { - encrypted_password: arrayBufferToBase64(encrypted), - password_key_id: key.key_id, - } + return arrayBufferToBase64(encrypted) } async function importRSAOAEPKey(publicKeyPEM: string): Promise { diff --git a/apps/admin-web/src/stores/auth.ts b/apps/admin-web/src/stores/auth.ts index 00d306b..262b442 100644 --- a/apps/admin-web/src/stores/auth.ts +++ b/apps/admin-web/src/stores/auth.ts @@ -191,7 +191,11 @@ export const useAuthStore = defineStore('auth', () => { async function logout(): Promise { try { if (accessToken.value) { - await authApi.logout() + try { + await authApi.logout() + } catch { + // Local logout must still complete when the server has already revoked the token. + } } } finally { clearStoredSession() diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 8ca9942..b161d7c 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -28,6 +28,14 @@ export interface LoginRequest { email?: string } +export interface ChangePasswordRequest { + old_password?: string + new_password?: string + encrypted_old_password?: string + encrypted_new_password?: string + password_key_id?: string +} + export interface PasswordPublicKeyResponse { key_id: string algorithm: 'RSA-OAEP-256' diff --git a/server/internal/shared/auth/claims.go b/server/internal/shared/auth/claims.go index afadda8..ec5b7d8 100644 --- a/server/internal/shared/auth/claims.go +++ b/server/internal/shared/auth/claims.go @@ -9,5 +9,6 @@ type Claims struct { TenantID int64 `json:"tenant_id"` PrimaryWorkspaceID int64 `json:"primary_workspace_id"` Role string `json:"role"` + SessionVersion int64 `json:"session_version,omitempty"` jwt.RegisteredClaims } diff --git a/server/internal/shared/auth/jwt.go b/server/internal/shared/auth/jwt.go index 8890cc2..6dc824f 100644 --- a/server/internal/shared/auth/jwt.go +++ b/server/internal/shared/auth/jwt.go @@ -43,6 +43,10 @@ type TokenPair struct { } func (m *Manager) Issue(userID, tenantID, primaryWorkspaceID int64, role string) (*TokenPair, error) { + return m.IssueWithSessionVersion(userID, tenantID, primaryWorkspaceID, role, 0) +} + +func (m *Manager) IssueWithSessionVersion(userID, tenantID, primaryWorkspaceID int64, role string, sessionVersion int64) (*TokenPair, error) { secret, accessTTL, refreshTTL := m.snapshot() now := time.Now() accessJTI := uuid.New().String() @@ -53,6 +57,7 @@ func (m *Manager) Issue(userID, tenantID, primaryWorkspaceID int64, role string) TenantID: tenantID, PrimaryWorkspaceID: primaryWorkspaceID, Role: role, + SessionVersion: sessionVersion, RegisteredClaims: jwt.RegisteredClaims{ ID: accessJTI, Subject: "access", @@ -70,6 +75,7 @@ func (m *Manager) Issue(userID, tenantID, primaryWorkspaceID int64, role string) TenantID: tenantID, PrimaryWorkspaceID: primaryWorkspaceID, Role: role, + SessionVersion: sessionVersion, RegisteredClaims: jwt.RegisteredClaims{ ID: refreshJTI, Subject: "refresh", diff --git a/server/internal/shared/auth/jwt_test.go b/server/internal/shared/auth/jwt_test.go index 52381aa..b2c1770 100644 --- a/server/internal/shared/auth/jwt_test.go +++ b/server/internal/shared/auth/jwt_test.go @@ -27,6 +27,7 @@ func TestIssueAndParse(t *testing.T) { assert.Equal(t, "tenant_admin", claims.Role) assert.Equal(t, "access", claims.Subject) assert.Equal(t, pair.AccessJTI, claims.ID) + assert.Equal(t, int64(0), claims.SessionVersion) } func TestParseRefreshToken(t *testing.T) { @@ -41,6 +42,21 @@ func TestParseRefreshToken(t *testing.T) { assert.Equal(t, pair.RefreshJTI, claims.ID) } +func TestIssueWithSessionVersion(t *testing.T) { + mgr := NewManager("test-secret-key", 15*time.Minute, 7*24*time.Hour) + + pair, err := mgr.IssueWithSessionVersion(1, 1, 10, "viewer", 3) + require.NoError(t, err) + + accessClaims, err := mgr.Parse(pair.AccessToken) + require.NoError(t, err) + assert.Equal(t, int64(3), accessClaims.SessionVersion) + + refreshClaims, err := mgr.Parse(pair.RefreshToken) + require.NoError(t, err) + assert.Equal(t, int64(3), refreshClaims.SessionVersion) +} + func TestParseExpired(t *testing.T) { mgr := NewManager("test-secret-key", -1*time.Second, -1*time.Second) diff --git a/server/internal/shared/auth/middleware.go b/server/internal/shared/auth/middleware.go index f46bae6..2c80b82 100644 --- a/server/internal/shared/auth/middleware.go +++ b/server/internal/shared/auth/middleware.go @@ -75,6 +75,17 @@ func Middleware(jwtMgr *Manager, sessions *SessionStore) gin.HandlerFunc { return } + currentVersion, _ := sessions.SessionVersion(c.Request.Context(), claims.UserID) + if claims.SessionVersion < currentVersion { + updateRequestLogContext(c, func(meta *RequestLogContext) { + meta.FailureStage = "check_session_version" + meta.FailureReason = "session_version_stale" + }) + response.Error(c, response.ErrUnauthorized(40103, "token_revoked", "token has been revoked")) + c.Abort() + return + } + revoked, _ := sessions.IsBlacklisted(c.Request.Context(), claims.ID) if revoked { updateRequestLogContext(c, func(meta *RequestLogContext) { diff --git a/server/internal/shared/auth/middleware_test.go b/server/internal/shared/auth/middleware_test.go index 5c7dc8d..511c25d 100644 --- a/server/internal/shared/auth/middleware_test.go +++ b/server/internal/shared/auth/middleware_test.go @@ -123,3 +123,23 @@ func TestAuthMiddleware_BlacklistedToken(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, w.Code) } + +func TestAuthMiddleware_StaleSessionVersionRejected(t *testing.T) { + mgr, sessions, _ := setupTestMiddleware(t) + + pair, err := mgr.IssueWithSessionVersion(1, 10, 100, "editor", 0) + require.NoError(t, err) + _, err = sessions.BumpSessionVersion(t.Context(), 1) + require.NoError(t, err) + + r := gin.New() + r.Use(Middleware(mgr, sessions)) + r.GET("/test", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+pair.AccessToken) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} diff --git a/server/internal/shared/auth/session_store.go b/server/internal/shared/auth/session_store.go index dc68d72..46ec45e 100644 --- a/server/internal/shared/auth/session_store.go +++ b/server/internal/shared/auth/session_store.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "sync" "time" @@ -28,6 +29,19 @@ redis.call("PSETEX", KEYS[2], ARGV[3], ARGV[2]) return 1 `) +var setMaxSessionVersionScript = redis.NewScript(` +local current = redis.call("GET", KEYS[1]) +local current_number = tonumber(current or "0") or 0 +local next_number = tonumber(ARGV[1]) or 0 +if current_number >= next_number then + return current_number +end +redis.call("SET", KEYS[1], ARGV[1]) +return next_number +`) + +const sessionVersionFallbackTTL = 30 * 24 * time.Hour + type SessionStore struct { rdb *redis.Client fallback *memorySessionStore @@ -138,6 +152,70 @@ func (s *SessionStore) IsBlacklisted(ctx context.Context, accessJTI string) (boo return n > 0, nil } +func (s *SessionStore) SessionVersion(ctx context.Context, userID int64) (int64, error) { + if s == nil { + return 0, nil + } + key := sessionVersionKey(userID) + fallbackVersion := s.fallback.sessionVersion(key) + redisVersion := int64(0) + if s.rdb != nil { + value, err := s.rdb.Get(ctx, key).Result() + if err == nil { + version, parseErr := strconv.ParseInt(value, 10, 64) + if parseErr == nil && version > 0 { + redisVersion = version + } + } + } + + version := maxInt64(redisVersion, fallbackVersion) + if version <= 0 { + return 0, nil + } + s.fallback.set(key, strconv.FormatInt(version, 10), sessionVersionFallbackTTL) + if s.rdb != nil && version > redisVersion { + _, _ = setMaxSessionVersionScript.Run(ctx, s.rdb, []string{key}, strconv.FormatInt(version, 10)).Int64() + } + return version, nil +} + +func (s *SessionStore) BumpSessionVersion(ctx context.Context, userID int64) (int64, error) { + if s == nil { + return 0, nil + } + key := sessionVersionKey(userID) + current, _ := s.SessionVersion(ctx, userID) + next := current + 1 + s.fallback.set(key, strconv.FormatInt(next, 10), sessionVersionFallbackTTL) + + if s.rdb == nil { + return next, nil + } + version, err := s.rdb.Incr(ctx, key).Result() + if err != nil { + return next, nil + } + if version < next { + if maxVersion, maxErr := setMaxSessionVersionScript.Run(ctx, s.rdb, []string{key}, strconv.FormatInt(next, 10)).Int64(); maxErr == nil { + version = maxVersion + } + } + s.fallback.set(key, strconv.FormatInt(version, 10), sessionVersionFallbackTTL) + return version, nil +} + +func sessionVersionKey(userID int64) string { + return fmt.Sprintf("session_version:user:%d", userID) +} + +func maxInt64(a, b int64) int64 { + if a > b { + return a + } + return b +} + type memorySessionStore struct { mu sync.Mutex items map[string]memorySessionItem @@ -190,6 +268,21 @@ func (s *memorySessionStore) delete(key string) { delete(s.items, key) } +func (s *memorySessionStore) sessionVersion(key string) int64 { + if s == nil { + return 0 + } + s.mu.Lock() + defer s.mu.Unlock() + + if item, ok := s.items[key]; ok && item.expiresAt.After(time.Now()) { + if parsed, err := strconv.ParseInt(item.value, 10, 64); err == nil && parsed > 0 { + return parsed + } + } + return 0 +} + func (s *memorySessionStore) rotate(oldKey, oldHash, newKey, newHash string, ttl time.Duration) error { if s == nil { return ErrRefreshSessionNotFound diff --git a/server/internal/shared/auth/session_store_test.go b/server/internal/shared/auth/session_store_test.go index 2c9442e..f2d5fb3 100644 --- a/server/internal/shared/auth/session_store_test.go +++ b/server/internal/shared/auth/session_store_test.go @@ -85,3 +85,43 @@ func TestSessionStoreUsesMemoryFallbackWhenRedisUnavailable(t *testing.T) { require.NoError(t, err) assert.True(t, revoked) } + +func TestSessionStoreBumpSessionVersion(t *testing.T) { + mr := miniredis.RunT(t) + client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + + store := NewSessionStore(client) + ctx := context.Background() + + version, err := store.SessionVersion(ctx, 42) + require.NoError(t, err) + assert.Equal(t, int64(0), version) + + version, err = store.BumpSessionVersion(ctx, 42) + require.NoError(t, err) + assert.Equal(t, int64(1), version) + + version, err = store.SessionVersion(ctx, 42) + require.NoError(t, err) + assert.Equal(t, int64(1), version) +} + +func TestSessionStoreSessionVersionKeepsFallbackBumpWhenRedisLags(t *testing.T) { + mr := miniredis.RunT(t) + client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + + store := NewSessionStore(client) + ctx := context.Background() + + require.NoError(t, mr.Set(sessionVersionKey(42), "1")) + store.fallback.set(sessionVersionKey(42), "3", sessionVersionFallbackTTL) + + version, err := store.SessionVersion(ctx, 42) + require.NoError(t, err) + assert.Equal(t, int64(3), version) + stored, err := mr.Get(sessionVersionKey(42)) + require.NoError(t, err) + assert.Equal(t, "3", stored) +} diff --git a/server/internal/shared/swagger/descriptions.go b/server/internal/shared/swagger/descriptions.go index b83c6b4..d3e4414 100644 --- a/server/internal/shared/swagger/descriptions.go +++ b/server/internal/shared/swagger/descriptions.go @@ -23,6 +23,7 @@ var routeDocs = map[string]routeDoc{ "POST /api/auth/login": {"账号密码登录", "客户后台账号密码登录,返回 access/refresh token。"}, "POST /api/auth/refresh": {"刷新访问令牌", "用 refresh token 换取新的 access token。"}, "GET /api/auth/me": {"当前登录用户", "返回当前 JWT 对应的用户、租户、Workspace 等会话信息。"}, + "POST /api/auth/password": {"修改密码", "当前登录用户修改自己的密码,成功后吊销该用户既有登录会话。"}, "POST /api/auth/logout": {"退出登录", "吊销当前会话,使其 access/refresh token 立即失效。"}, // --- Public 资源 --- diff --git a/server/internal/tenant/app/auth_service.go b/server/internal/tenant/app/auth_service.go index d05f1b6..571101f 100644 --- a/server/internal/tenant/app/auth_service.go +++ b/server/internal/tenant/app/auth_service.go @@ -51,6 +51,21 @@ func (s *AuthService) WithPasswordCipher(cipher *auth.PasswordCipher) *AuthServi return s } +func (s *AuthService) sessionVersion(ctx context.Context, userID int64) (int64, error) { + if s == nil || s.sessions == nil { + return 0, nil + } + return s.sessions.SessionVersion(ctx, userID) +} + +func (s *AuthService) revokeUserSessions(ctx context.Context, userID int64) error { + if s == nil || s.sessions == nil { + return nil + } + _, err := s.sessions.BumpSessionVersion(ctx, userID) + return err +} + type LoginRequest struct { Identifier string `json:"identifier"` Email string `json:"email"` @@ -153,7 +168,11 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon return nil, response.ErrForbidden(40301, "no_tenant", "user has no active tenant membership") } - pair, err := s.jwt.Issue(user.ID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole) + sessionVersion, err := s.sessionVersion(ctx, user.ID) + if err != nil { + return nil, fmt.Errorf("load session version: %w", err) + } + pair, err := s.jwt.IssueWithSessionVersion(user.ID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole, sessionVersion) if err != nil { return nil, fmt.Errorf("issue token: %w", err) } @@ -261,6 +280,14 @@ type RefreshResponse struct { ExpiresAt int64 `json:"expires_at"` } +type ChangePasswordRequest struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + EncryptedOldPassword string `json:"encrypted_old_password"` + EncryptedNewPassword string `json:"encrypted_new_password"` + PasswordKeyID string `json:"password_key_id"` +} + func (s *AuthService) Refresh(ctx context.Context, req RefreshRequest) (*RefreshResponse, error) { claims, err := s.jwt.Parse(req.RefreshToken) if err != nil || claims.Subject != "refresh" { @@ -272,7 +299,15 @@ func (s *AuthService) Refresh(ctx context.Context, req RefreshRequest) (*Refresh return nil, response.ErrUnauthorized(40123, "membership_revoked", "tenant membership is no longer active") } - pair, err := s.jwt.Issue(membership.UserID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole) + currentVersion, err := s.sessionVersion(ctx, claims.UserID) + if err != nil { + return nil, fmt.Errorf("load session version: %w", err) + } + if claims.SessionVersion < currentVersion { + return nil, response.ErrUnauthorized(40124, "session_revoked", "session has been revoked") + } + + pair, err := s.jwt.IssueWithSessionVersion(membership.UserID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole, currentVersion) if err != nil { return nil, fmt.Errorf("issue token: %w", err) } @@ -302,6 +337,59 @@ func (s *AuthService) Refresh(ctx context.Context, req RefreshRequest) (*Refresh }, nil } +func (s *AuthService) ChangePassword(ctx context.Context, actor auth.Actor, req ChangePasswordRequest) error { + oldPassword, newPassword, err := s.changePasswordValues(req) + if err != nil { + return err + } + if oldPassword == "" { + return response.ErrBadRequest(40001, "invalid_params", "old password is required") + } + if len(newPassword) < 8 { + return response.ErrBadRequest(40011, "weak_password", "新密码至少 8 位") + } + + user, err := s.repo.GetUserByID(ctx, actor.UserID) + if err != nil { + return response.ErrNotFound(40401, "user_not_found", "user not found") + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldPassword)); err != nil { + return response.ErrUnauthorized(40111, "invalid_old_password", "原密码错误") + } + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + if err := s.repo.UpdateUserPassword(ctx, actor.UserID, string(hash)); err != nil { + return fmt.Errorf("update user password: %w", err) + } + if err := s.revokeUserSessions(ctx, actor.UserID); err != nil { + return fmt.Errorf("revoke user sessions: %w", err) + } + return nil +} + +func (s *AuthService) changePasswordValues(req ChangePasswordRequest) (string, string, error) { + if strings.TrimSpace(req.EncryptedOldPassword) != "" || strings.TrimSpace(req.EncryptedNewPassword) != "" { + if strings.TrimSpace(req.EncryptedOldPassword) == "" || strings.TrimSpace(req.EncryptedNewPassword) == "" { + return "", "", response.ErrBadRequest(40001, "invalid_params", "old password and new password are required") + } + if s.cipher == nil { + return "", "", response.ErrBadRequest(40002, "password_cipher_unavailable", "encrypted password is not supported by this server") + } + oldPassword, err := s.cipher.Decrypt(req.EncryptedOldPassword, req.PasswordKeyID) + if err != nil { + return "", "", response.ErrBadRequest(40003, "invalid_encrypted_password", "encrypted password is invalid") + } + newPassword, err := s.cipher.Decrypt(req.EncryptedNewPassword, req.PasswordKeyID) + if err != nil { + return "", "", response.ErrBadRequest(40003, "invalid_encrypted_password", "encrypted password is invalid") + } + return oldPassword, newPassword, nil + } + return req.OldPassword, req.NewPassword, nil +} + func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, error) { user, err := s.repo.GetUserByID(ctx, actor.UserID) if err != nil { diff --git a/server/internal/tenant/app/auth_service_test.go b/server/internal/tenant/app/auth_service_test.go index fd2fec1..517e523 100644 --- a/server/internal/tenant/app/auth_service_test.go +++ b/server/internal/tenant/app/auth_service_test.go @@ -1,17 +1,21 @@ package app import ( + "context" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/pem" + "errors" "testing" "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/response" + "github.com/geo-platform/tenant-api/internal/tenant/repository" "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" ) func TestAuthServiceLoginPasswordDecryptsEncryptedPassword(t *testing.T) { @@ -58,3 +62,145 @@ func TestAuthServiceLoginPasswordRejectsEncryptedWhenCipherMissing(t *testing.T) require.True(t, ok) require.Equal(t, "password_cipher_unavailable", appErr.Message) } + +func TestAuthServiceChangePasswordUpdatesHash(t *testing.T) { + t.Parallel() + + oldHash, err := bcrypt.GenerateFromPassword([]byte("OldPass@123"), bcrypt.MinCost) + require.NoError(t, err) + repo := &authRepoStub{ + user: &repository.UserRecord{ + ID: 12, + Phone: "13800000000", + PasswordHash: string(oldHash), + Status: "active", + }, + } + sessions := auth.NewSessionStore(nil) + svc := &AuthService{repo: repo, sessions: sessions} + + err = svc.ChangePassword(context.Background(), auth.Actor{UserID: 12}, ChangePasswordRequest{ + OldPassword: "OldPass@123", + NewPassword: "NewPass@123", + }) + + require.NoError(t, err) + require.Equal(t, int64(12), repo.updatedUserID) + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(repo.updatedPasswordHash), []byte("NewPass@123"))) + version, err := sessions.SessionVersion(context.Background(), 12) + require.NoError(t, err) + require.Equal(t, int64(1), version) +} + +func TestAuthServiceChangePasswordRejectsInvalidOldPassword(t *testing.T) { + t.Parallel() + + oldHash, err := bcrypt.GenerateFromPassword([]byte("OldPass@123"), bcrypt.MinCost) + require.NoError(t, err) + repo := &authRepoStub{ + user: &repository.UserRecord{ + ID: 12, + Phone: "13800000000", + PasswordHash: string(oldHash), + Status: "active", + }, + } + svc := &AuthService{repo: repo} + + err = svc.ChangePassword(context.Background(), auth.Actor{UserID: 12}, ChangePasswordRequest{ + OldPassword: "wrong", + NewPassword: "NewPass@123", + }) + + require.Error(t, err) + appErr, ok := err.(*response.AppError) + require.True(t, ok) + require.Equal(t, "invalid_old_password", appErr.Message) + require.Empty(t, repo.updatedPasswordHash) +} + +func TestAuthServiceChangePasswordRejectsWeakPassword(t *testing.T) { + t.Parallel() + + repo := &authRepoStub{} + svc := &AuthService{repo: repo} + + err := svc.ChangePassword(context.Background(), auth.Actor{UserID: 12}, ChangePasswordRequest{ + OldPassword: "OldPass@123", + NewPassword: "short", + }) + + require.Error(t, err) + appErr, ok := err.(*response.AppError) + require.True(t, ok) + require.Equal(t, "weak_password", appErr.Message) + require.False(t, repo.getUserByIDCalled) +} + +func TestAuthServiceChangePasswordDecryptsEncryptedPasswords(t *testing.T) { + t.Parallel() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + privatePEM := string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + })) + cipher, err := auth.NewPasswordCipher(privatePEM, "tenant-change-key") + require.NoError(t, err) + + encryptedOld, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &key.PublicKey, []byte("OldPass@123"), nil) + require.NoError(t, err) + encryptedNew, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &key.PublicKey, []byte("NewPass@123"), nil) + require.NoError(t, err) + + svc := (&AuthService{}).WithPasswordCipher(cipher) + oldPassword, newPassword, err := svc.changePasswordValues(ChangePasswordRequest{ + EncryptedOldPassword: base64.StdEncoding.EncodeToString(encryptedOld), + EncryptedNewPassword: base64.StdEncoding.EncodeToString(encryptedNew), + PasswordKeyID: "tenant-change-key", + }) + + require.NoError(t, err) + require.Equal(t, "OldPass@123", oldPassword) + require.Equal(t, "NewPass@123", newPassword) +} + +type authRepoStub struct { + user *repository.UserRecord + getUserByIDCalled bool + updatedUserID int64 + updatedPasswordHash string +} + +func (r *authRepoStub) GetUserByEmail(context.Context, string) (*repository.UserRecord, error) { + return nil, errors.New("not implemented") +} + +func (r *authRepoStub) GetUserByLoginIdentifier(context.Context, string) (*repository.UserRecord, error) { + return nil, errors.New("not implemented") +} + +func (r *authRepoStub) GetUserByID(context.Context, int64) (*repository.UserRecord, error) { + r.getUserByIDCalled = true + if r.user == nil { + return nil, errors.New("not found") + } + return r.user, nil +} + +func (r *authRepoStub) UpdateUserPassword(_ context.Context, id int64, passwordHash string) error { + r.updatedUserID = id + r.updatedPasswordHash = passwordHash + return nil +} + +func (r *authRepoStub) GetTenantMembership(context.Context, int64) (*repository.MembershipRecord, error) { + return nil, errors.New("not implemented") +} + +func (r *authRepoStub) GetTenantMembershipByTenantAndUser(context.Context, int64, int64) (*repository.MembershipRecord, error) { + return nil, errors.New("not implemented") +} + +var _ repository.AuthRepository = (*authRepoStub)(nil) diff --git a/server/internal/tenant/app/cache_support.go b/server/internal/tenant/app/cache_support.go index 1cd74c4..f850164 100644 --- a/server/internal/tenant/app/cache_support.go +++ b/server/internal/tenant/app/cache_support.go @@ -152,42 +152,42 @@ func invalidateBrandCaches(ctx context.Context, c sharedcache.Cache, tenantID, b invalidateWorkspaceCaches(ctx, c, tenantID) } -func promptRuleGroupsCacheKey(tenantID int64) string { - return fmt.Sprintf("prompt_rule:groups:%d", tenantID) +func promptRuleGroupsCacheKey(tenantID, brandID int64) string { + return fmt.Sprintf("prompt_rule:groups:%d:%d", tenantID, brandID) } -func promptRuleSimpleCacheKey(tenantID int64) string { - return fmt.Sprintf("prompt_rule:simple:%d", tenantID) +func promptRuleSimpleCacheKey(tenantID, brandID int64) string { + return fmt.Sprintf("prompt_rule:simple:%d:%d", tenantID, brandID) } -func promptRuleDetailCachePrefix(tenantID int64) string { - return fmt.Sprintf("prompt_rule:detail:%d:", tenantID) +func promptRuleDetailCachePrefix(tenantID, brandID int64) string { + return fmt.Sprintf("prompt_rule:detail:%d:%d:", tenantID, brandID) } -func promptRuleDetailCacheKey(tenantID, ruleID int64) string { - return fmt.Sprintf("%s%d", promptRuleDetailCachePrefix(tenantID), ruleID) +func promptRuleDetailCacheKey(tenantID, brandID, ruleID int64) string { + return fmt.Sprintf("%s%d", promptRuleDetailCachePrefix(tenantID, brandID), ruleID) } -func promptRuleListCachePrefix(tenantID int64) string { - return fmt.Sprintf("prompt_rule:list:%d:", tenantID) +func promptRuleListCachePrefix(tenantID, brandID int64) string { + return fmt.Sprintf("prompt_rule:list:%d:%d:", tenantID, brandID) } -func promptRuleListCacheKey(tenantID int64, params interface{}) string { - return promptRuleListCachePrefix(tenantID) + digestCacheKey(params) +func promptRuleListCacheKey(tenantID, brandID int64, params interface{}) string { + return promptRuleListCachePrefix(tenantID, brandID) + digestCacheKey(params) } func articleListCachePrefix(tenantID int64) string { return fmt.Sprintf("article:list:%d:", tenantID) } -func invalidatePromptRuleCaches(ctx context.Context, c sharedcache.Cache, tenantID int64, ruleID *int64) { - deleteCacheKey(ctx, c, promptRuleGroupsCacheKey(tenantID)) - deleteCacheKey(ctx, c, promptRuleSimpleCacheKey(tenantID)) - deleteCachePrefix(ctx, c, promptRuleListCachePrefix(tenantID)) +func invalidatePromptRuleCaches(ctx context.Context, c sharedcache.Cache, tenantID, brandID int64, ruleID *int64) { + deleteCacheKey(ctx, c, promptRuleGroupsCacheKey(tenantID, brandID)) + deleteCacheKey(ctx, c, promptRuleSimpleCacheKey(tenantID, brandID)) + deleteCachePrefix(ctx, c, promptRuleListCachePrefix(tenantID, brandID)) if ruleID != nil && *ruleID > 0 { - deleteCacheKey(ctx, c, promptRuleDetailCacheKey(tenantID, *ruleID)) + deleteCacheKey(ctx, c, promptRuleDetailCacheKey(tenantID, brandID, *ruleID)) } else { - deleteCachePrefix(ctx, c, promptRuleDetailCachePrefix(tenantID)) + deleteCachePrefix(ctx, c, promptRuleDetailCachePrefix(tenantID, brandID)) } deleteCachePrefix(ctx, c, scheduleTaskListCachePrefix(tenantID)) deleteCachePrefix(ctx, c, scheduleTaskDetailCachePrefix(tenantID)) diff --git a/server/internal/tenant/app/generation_task_runtime.go b/server/internal/tenant/app/generation_task_runtime.go index fe441a1..887da8a 100644 --- a/server/internal/tenant/app/generation_task_runtime.go +++ b/server/internal/tenant/app/generation_task_runtime.go @@ -26,6 +26,11 @@ type ClaimedGenerationTask struct { const generationCleanupTimeout = 30 * time.Second +type promptRuleTaskContext struct { + PromptRuleID int64 + BrandID int64 +} + func newGenerationCleanupContext() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), generationCleanupTimeout) } @@ -215,13 +220,20 @@ func (s *PromptRuleGenerationService) ProcessQueuedTask(ctx context.Context, tas } rule := extractPromptRuleSnapshot(inputParams) - if rule == nil { - promptRuleID, err := s.resolvePromptRuleIDForTask(ctx, task) + if rule == nil || int64(extractInt(inputParams, "prompt_rule_id")) <= 0 || int64(extractInt(inputParams, "brand_id")) <= 0 { + taskContext, err := s.resolvePromptRuleTaskContext(ctx, task) if err != nil { s.failGeneration(context.Background(), job, initialTitle, "task_load", err) return } - rule, err = s.loadPromptRule(ctx, task.TenantID, promptRuleID) + inputParams["prompt_rule_id"] = taskContext.PromptRuleID + inputParams["brand_id"] = taskContext.BrandID + } + if rule == nil { + promptRuleID := int64(extractInt(inputParams, "prompt_rule_id")) + brandID := int64(extractInt(inputParams, "brand_id")) + var err error + rule, err = s.loadPromptRule(ctx, task.TenantID, brandID, promptRuleID) if err != nil { s.failGeneration(context.Background(), job, initialTitle, "task_load", err) return @@ -254,6 +266,47 @@ func (s *PromptRuleGenerationService) ProcessQueuedTask(ctx context.Context, tas s.executeGeneration(ctx, job) } +func (s *PromptRuleGenerationService) resolvePromptRuleTaskContext(ctx context.Context, task ClaimedGenerationTask) (promptRuleTaskContext, error) { + resolved := promptRuleTaskContext{ + PromptRuleID: int64(extractInt(task.InputParams, "prompt_rule_id")), + BrandID: int64(extractInt(task.InputParams, "brand_id")), + } + if resolved.PromptRuleID > 0 && resolved.BrandID > 0 { + return resolved, nil + } + + if task.ArticleID <= 0 { + return resolved, fmt.Errorf("article context missing for prompt rule task") + } + + var articlePromptRuleID int64 + var articleBrandID int64 + if err := s.pool.QueryRow(ctx, ` + SELECT COALESCE(prompt_rule_id, 0), brand_id + FROM articles + WHERE id = $1 + AND tenant_id = $2 + AND source_type = 'custom_generation' + AND deleted_at IS NULL + `, task.ArticleID, task.TenantID).Scan(&articlePromptRuleID, &articleBrandID); err != nil { + return resolved, fmt.Errorf("load task prompt rule context: %w", err) + } + + if resolved.PromptRuleID <= 0 { + resolved.PromptRuleID = articlePromptRuleID + } + if resolved.BrandID <= 0 { + resolved.BrandID = articleBrandID + } + if resolved.PromptRuleID <= 0 { + return resolved, fmt.Errorf("prompt rule context missing for prompt rule task") + } + if resolved.BrandID <= 0 { + return resolved, fmt.Errorf("brand context missing for prompt rule task") + } + return resolved, nil +} + func (s *PromptRuleGenerationService) resolvePromptRuleIDForTask(ctx context.Context, task ClaimedGenerationTask) (int64, error) { if promptRuleID := int64(extractInt(task.InputParams, "prompt_rule_id")); promptRuleID > 0 { return promptRuleID, nil diff --git a/server/internal/tenant/app/prompt_generate_service.go b/server/internal/tenant/app/prompt_generate_service.go index ff291a6..63325b6 100644 --- a/server/internal/tenant/app/prompt_generate_service.go +++ b/server/internal/tenant/app/prompt_generate_service.go @@ -267,7 +267,7 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co return nil, response.ErrBadRequest(40033, "brand_context_required", "current brand is required") } - rule, err := s.loadPromptRule(ctx, input.TenantID, input.PromptRuleID) + rule, err := s.loadPromptRule(ctx, input.TenantID, *input.BrandID, input.PromptRuleID) if err != nil { return nil, err } @@ -476,13 +476,13 @@ func (s *PromptRuleGenerationService) enqueuePromptRuleGeneration(ctx context.Co return &GenerateFromRuleResponse{ArticleID: articleID, TaskID: taskID}, nil } -func (s *PromptRuleGenerationService) loadPromptRule(ctx context.Context, tenantID, promptRuleID int64) (*promptRuleGenerationRecord, error) { +func (s *PromptRuleGenerationService) loadPromptRule(ctx context.Context, tenantID, brandID, promptRuleID int64) (*promptRuleGenerationRecord, error) { record := &promptRuleGenerationRecord{} err := s.pool.QueryRow(ctx, ` SELECT id, name, prompt_content, scene, default_tone, default_word_count, status FROM prompt_rules - WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL - `, promptRuleID, tenantID).Scan( + WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL + `, promptRuleID, tenantID, brandID).Scan( &record.ID, &record.Name, &record.PromptContent, diff --git a/server/internal/tenant/app/prompt_rule_service.go b/server/internal/tenant/app/prompt_rule_service.go index 42056e9..8a2bc87 100644 --- a/server/internal/tenant/app/prompt_rule_service.go +++ b/server/internal/tenant/app/prompt_rule_service.go @@ -107,23 +107,31 @@ type PromptRuleSimple struct { func (s *PromptRuleService) ListGroups(ctx context.Context) ([]PromptRuleGroupResponse, error) { actor := auth.MustActor(ctx) - return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, promptRuleGroupsCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]PromptRuleGroupResponse, error) { - return s.loadPromptRuleGroups(loadCtx, actor.TenantID) + brandID, err := requireCurrentBrandID(ctx) + if err != nil { + return nil, err + } + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, promptRuleGroupsCacheKey(actor.TenantID, brandID), defaultCacheTTL(), func(loadCtx context.Context) ([]PromptRuleGroupResponse, error) { + return s.loadPromptRuleGroups(loadCtx, actor.TenantID, brandID) }) } func (s *PromptRuleService) CreateGroup(ctx context.Context, req PromptRuleGroupRequest) (*PromptRuleGroupResponse, error) { actor := auth.MustActor(ctx) + brandID, err := requireCurrentBrandID(ctx) + if err != nil { + return nil, err + } sortOrder := 0 if req.SortOrder != nil { sortOrder = *req.SortOrder } var id int64 var ca interface{} - err := s.pool.QueryRow(ctx, ` - INSERT INTO prompt_rule_groups (tenant_id, name, sort_order) - VALUES ($1, $2, $3) RETURNING id, created_at - `, actor.TenantID, req.Name, sortOrder).Scan(&id, &ca) + err = s.pool.QueryRow(ctx, ` + INSERT INTO prompt_rule_groups (tenant_id, brand_id, name, sort_order) + VALUES ($1, $2, $3, $4) RETURNING id, created_at + `, actor.TenantID, brandID, req.Name, sortOrder).Scan(&id, &ca) if err != nil { return nil, response.ErrInternal(50010, "create_failed", "failed to create group") } @@ -144,40 +152,48 @@ func (s *PromptRuleService) CreateGroup(ctx context.Context, req PromptRuleGroup Result: &result, }) - invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, nil) + invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, brandID, nil) return &PromptRuleGroupResponse{ID: id, Name: req.Name, SortOrder: sortOrder, CreatedAt: fmt.Sprintf("%v", ca)}, nil } func (s *PromptRuleService) UpdateGroup(ctx context.Context, id int64, req PromptRuleGroupRequest) error { actor := auth.MustActor(ctx) + brandID, err := requireCurrentBrandID(ctx) + if err != nil { + return err + } sortOrder := 0 if req.SortOrder != nil { sortOrder = *req.SortOrder } tag, err := s.pool.Exec(ctx, ` UPDATE prompt_rule_groups SET name = $1, sort_order = $2, updated_at = NOW() - WHERE id = $3 AND tenant_id = $4 AND deleted_at IS NULL - `, req.Name, sortOrder, id, actor.TenantID) + WHERE id = $3 AND tenant_id = $4 AND brand_id = $5 AND deleted_at IS NULL + `, req.Name, sortOrder, id, actor.TenantID, brandID) if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40430, "group_not_found", "prompt rule group not found") } - invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, nil) + invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, brandID, nil) return nil } func (s *PromptRuleService) DeleteGroup(ctx context.Context, id int64) error { actor := auth.MustActor(ctx) + brandID, err := requireCurrentBrandID(ctx) + if err != nil { + return err + } // Move rules in this group to ungrouped _, _ = s.pool.Exec(ctx, ` UPDATE prompt_rules SET group_id = NULL, updated_at = NOW() - WHERE group_id = $1 AND tenant_id = $2 AND deleted_at IS NULL - `, id, actor.TenantID) + WHERE group_id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL + `, id, actor.TenantID, brandID) tag, err := s.pool.Exec(ctx, ` UPDATE prompt_rule_groups SET deleted_at = NOW(), updated_at = NOW() - WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL - `, id, actor.TenantID) + WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL + `, id, actor.TenantID, brandID) if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40430, "group_not_found", "prompt rule group not found") } @@ -197,7 +213,7 @@ func (s *PromptRuleService) DeleteGroup(ctx context.Context, id int64) error { AfterJSON: afterJSON, Result: &result, }) - invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, nil) + invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, brandID, nil) return nil } @@ -205,15 +221,23 @@ func (s *PromptRuleService) DeleteGroup(ctx context.Context, id int64) error { func (s *PromptRuleService) List(ctx context.Context, params PromptRuleListParams) (*PromptRuleListResponse, error) { actor := auth.MustActor(ctx) - return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, promptRuleListCacheKey(actor.TenantID, params), defaultCacheTTL(), func(loadCtx context.Context) (*PromptRuleListResponse, error) { - return s.loadPromptRules(loadCtx, actor.TenantID, params) + brandID, err := requireCurrentBrandID(ctx) + if err != nil { + return nil, err + } + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, promptRuleListCacheKey(actor.TenantID, brandID, params), defaultCacheTTL(), func(loadCtx context.Context) (*PromptRuleListResponse, error) { + return s.loadPromptRules(loadCtx, actor.TenantID, brandID, params) }) } func (s *PromptRuleService) Detail(ctx context.Context, id int64) (*PromptRuleResponse, error) { actor := auth.MustActor(ctx) - record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, promptRuleDetailCacheKey(actor.TenantID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*PromptRuleResponse, bool, error) { - return s.loadPromptRuleDetail(loadCtx, actor.TenantID, id) + brandID, err := requireCurrentBrandID(ctx) + if err != nil { + return nil, err + } + record, found, err := sharedcache.LoadJSONWithEmpty(ctx, s.cache, &s.cacheGroup, promptRuleDetailCacheKey(actor.TenantID, brandID, id), defaultCacheTTL(), defaultCacheEmptyTTL(), func(loadCtx context.Context) (*PromptRuleResponse, bool, error) { + return s.loadPromptRuleDetail(loadCtx, actor.TenantID, brandID, id) }) if err != nil { return nil, err @@ -226,10 +250,17 @@ func (s *PromptRuleService) Detail(ctx context.Context, id int64) (*PromptRuleRe func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) (*PromptRuleResponse, error) { actor := auth.MustActor(ctx) + brandID, err := requireCurrentBrandID(ctx) + if err != nil { + return nil, err + } knowledgeGroups, err := s.validatePromptRuleKnowledgeGroups(ctx, actor.TenantID, req.KnowledgeGroupIDs) if err != nil { return nil, err } + if err := s.validatePromptRuleGroup(ctx, actor.TenantID, brandID, req.GroupID); err != nil { + return nil, err + } tx, err := s.pool.Begin(ctx) if err != nil { @@ -240,9 +271,9 @@ func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) ( var id int64 var ca interface{} err = tx.QueryRow(ctx, ` - INSERT INTO prompt_rules (tenant_id, group_id, name, prompt_content, scene, default_tone, default_word_count) - VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at - `, actor.TenantID, req.GroupID, req.Name, req.PromptContent, req.Scene, req.DefaultTone, req.DefaultWordCount).Scan(&id, &ca) + INSERT INTO prompt_rules (tenant_id, brand_id, group_id, name, prompt_content, scene, default_tone, default_word_count) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at + `, actor.TenantID, brandID, req.GroupID, req.Name, req.PromptContent, req.Scene, req.DefaultTone, req.DefaultWordCount).Scan(&id, &ca) if err != nil { return nil, response.ErrInternal(50010, "create_failed", "failed to create prompt rule") } @@ -270,7 +301,7 @@ func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) ( Result: &result, }) - invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, &id) + invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, brandID, &id) return &PromptRuleResponse{ ID: id, GroupID: req.GroupID, @@ -290,10 +321,17 @@ func (s *PromptRuleService) Create(ctx context.Context, req PromptRuleRequest) ( func (s *PromptRuleService) Update(ctx context.Context, id int64, req PromptRuleRequest) error { actor := auth.MustActor(ctx) + brandID, err := requireCurrentBrandID(ctx) + if err != nil { + return err + } knowledgeGroups, err := s.validatePromptRuleKnowledgeGroups(ctx, actor.TenantID, req.KnowledgeGroupIDs) if err != nil { return err } + if err := s.validatePromptRuleGroup(ctx, actor.TenantID, brandID, req.GroupID); err != nil { + return err + } tx, err := s.pool.Begin(ctx) if err != nil { @@ -304,8 +342,8 @@ func (s *PromptRuleService) Update(ctx context.Context, id int64, req PromptRule tag, err := tx.Exec(ctx, ` UPDATE prompt_rules SET name = $1, group_id = $2, prompt_content = $3, scene = $4, default_tone = $5, default_word_count = $6, updated_at = NOW() - WHERE id = $7 AND tenant_id = $8 AND deleted_at IS NULL - `, req.Name, req.GroupID, req.PromptContent, req.Scene, req.DefaultTone, req.DefaultWordCount, id, actor.TenantID) + WHERE id = $7 AND tenant_id = $8 AND brand_id = $9 AND deleted_at IS NULL + `, req.Name, req.GroupID, req.PromptContent, req.Scene, req.DefaultTone, req.DefaultWordCount, id, actor.TenantID, brandID) if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40431, "rule_not_found", "prompt rule not found") } @@ -315,16 +353,20 @@ func (s *PromptRuleService) Update(ctx context.Context, id int64, req PromptRule if err := tx.Commit(ctx); err != nil { return response.ErrInternal(50010, "update_failed", "failed to commit prompt rule") } - invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, &id) + invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, brandID, &id) return nil } func (s *PromptRuleService) Delete(ctx context.Context, id int64) error { actor := auth.MustActor(ctx) + brandID, err := requireCurrentBrandID(ctx) + if err != nil { + return err + } tag, err := s.pool.Exec(ctx, ` UPDATE prompt_rules SET deleted_at = NOW(), updated_at = NOW() - WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL - `, id, actor.TenantID) + WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL + `, id, actor.TenantID, brandID) if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40431, "rule_not_found", "prompt rule not found") } @@ -344,36 +386,45 @@ func (s *PromptRuleService) Delete(ctx context.Context, id int64) error { AfterJSON: afterJSON, Result: &result, }) - invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, &id) + invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, brandID, &id) return nil } func (s *PromptRuleService) ToggleStatus(ctx context.Context, id int64, req PromptRuleStatusRequest) error { actor := auth.MustActor(ctx) + brandID, err := requireCurrentBrandID(ctx) + if err != nil { + return err + } tag, err := s.pool.Exec(ctx, ` UPDATE prompt_rules SET status = $1, updated_at = NOW() - WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL - `, req.Status, id, actor.TenantID) + WHERE id = $2 AND tenant_id = $3 AND brand_id = $4 AND deleted_at IS NULL + `, req.Status, id, actor.TenantID, brandID) if err != nil || tag.RowsAffected() == 0 { return response.ErrNotFound(40431, "rule_not_found", "prompt rule not found") } - invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, &id) + invalidatePromptRuleCaches(ctx, s.cache, actor.TenantID, brandID, &id) return nil } func (s *PromptRuleService) ListSimple(ctx context.Context) ([]PromptRuleSimple, error) { actor := auth.MustActor(ctx) - return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, promptRuleSimpleCacheKey(actor.TenantID), defaultCacheTTL(), func(loadCtx context.Context) ([]PromptRuleSimple, error) { - return s.loadPromptRuleSimple(loadCtx, actor.TenantID) + brandID, err := requireCurrentBrandID(ctx) + if err != nil { + return nil, err + } + return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, promptRuleSimpleCacheKey(actor.TenantID, brandID), defaultCacheTTL(), func(loadCtx context.Context) ([]PromptRuleSimple, error) { + return s.loadPromptRuleSimple(loadCtx, actor.TenantID, brandID) }) } -func (s *PromptRuleService) loadPromptRuleGroups(ctx context.Context, tenantID int64) ([]PromptRuleGroupResponse, error) { +func (s *PromptRuleService) loadPromptRuleGroups(ctx context.Context, tenantID, brandID int64) ([]PromptRuleGroupResponse, error) { rows, err := s.pool.Query(ctx, ` SELECT id, name, sort_order, created_at - FROM prompt_rule_groups WHERE tenant_id = $1 AND deleted_at IS NULL + FROM prompt_rule_groups + WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL ORDER BY sort_order, created_at - `, tenantID) + `, tenantID, brandID) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to list groups") } @@ -392,7 +443,7 @@ func (s *PromptRuleService) loadPromptRuleGroups(ctx context.Context, tenantID i return items, nil } -func (s *PromptRuleService) loadPromptRules(ctx context.Context, tenantID int64, params PromptRuleListParams) (*PromptRuleListResponse, error) { +func (s *PromptRuleService) loadPromptRules(ctx context.Context, tenantID, brandID int64, params PromptRuleListParams) (*PromptRuleListResponse, error) { if params.Page < 1 { params.Page = 1 } @@ -407,8 +458,8 @@ func (s *PromptRuleService) loadPromptRules(ctx context.Context, tenantID int64, if params.Ungrouped { if err := s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM prompt_rules - WHERE tenant_id = $1 AND deleted_at IS NULL AND group_id IS NULL - `, tenantID).Scan(&total); err != nil { + WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL AND group_id IS NULL + `, tenantID, brandID).Scan(&total); err != nil { return nil, response.ErrInternal(50010, "count_failed", "failed to count rules") } @@ -418,12 +469,12 @@ func (s *PromptRuleService) loadPromptRules(ctx context.Context, tenantID int64, pr.created_at, pr.updated_at, COUNT(a.id)::INT AS article_count FROM prompt_rules pr - LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.deleted_at IS NULL - WHERE pr.tenant_id = $1 AND pr.deleted_at IS NULL AND pr.group_id IS NULL + LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.brand_id = pr.brand_id AND a.deleted_at IS NULL + WHERE pr.tenant_id = $1 AND pr.brand_id = $2 AND pr.deleted_at IS NULL AND pr.group_id IS NULL GROUP BY pr.id ORDER BY pr.created_at DESC - LIMIT $2 OFFSET $3 - `, tenantID, params.PageSize, offset) + LIMIT $3 OFFSET $4 + `, tenantID, brandID, params.PageSize, offset) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to list rules") } @@ -431,11 +482,11 @@ func (s *PromptRuleService) loadPromptRules(ctx context.Context, tenantID int64, } else { if err := s.pool.QueryRow(ctx, ` SELECT COUNT(*) FROM prompt_rules - WHERE tenant_id = $1 AND deleted_at IS NULL - AND ($2::bigint IS NULL OR group_id = $2) - AND ($3::text IS NULL OR status = $3) - AND ($4::text IS NULL OR name ILIKE '%' || $4 || '%') - `, tenantID, params.GroupID, params.Status, params.Keyword).Scan(&total); err != nil { + WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL + AND ($3::bigint IS NULL OR group_id = $3) + AND ($4::text IS NULL OR status = $4) + AND ($5::text IS NULL OR name ILIKE '%' || $5 || '%') + `, tenantID, brandID, params.GroupID, params.Status, params.Keyword).Scan(&total); err != nil { return nil, response.ErrInternal(50010, "count_failed", "failed to count rules") } @@ -445,15 +496,15 @@ func (s *PromptRuleService) loadPromptRules(ctx context.Context, tenantID int64, pr.created_at, pr.updated_at, COUNT(a.id)::INT AS article_count FROM prompt_rules pr - LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.deleted_at IS NULL - WHERE pr.tenant_id = $1 AND pr.deleted_at IS NULL - AND ($2::bigint IS NULL OR pr.group_id = $2) - AND ($3::text IS NULL OR pr.status = $3) - AND ($4::text IS NULL OR pr.name ILIKE '%' || $4 || '%') + LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.brand_id = pr.brand_id AND a.deleted_at IS NULL + WHERE pr.tenant_id = $1 AND pr.brand_id = $2 AND pr.deleted_at IS NULL + AND ($3::bigint IS NULL OR pr.group_id = $3) + AND ($4::text IS NULL OR pr.status = $4) + AND ($5::text IS NULL OR pr.name ILIKE '%' || $5 || '%') GROUP BY pr.id ORDER BY pr.created_at DESC - LIMIT $5 OFFSET $6 - `, tenantID, params.GroupID, params.Status, params.Keyword, params.PageSize, offset) + LIMIT $6 OFFSET $7 + `, tenantID, brandID, params.GroupID, params.Status, params.Keyword, params.PageSize, offset) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to list rules") } @@ -496,7 +547,7 @@ func (s *PromptRuleService) loadPromptRules(ctx context.Context, tenantID int64, return &PromptRuleListResponse{Items: items, Total: total}, nil } -func (s *PromptRuleService) loadPromptRuleDetail(ctx context.Context, tenantID, ruleID int64) (*PromptRuleResponse, bool, error) { +func (s *PromptRuleService) loadPromptRuleDetail(ctx context.Context, tenantID, brandID, ruleID int64) (*PromptRuleResponse, bool, error) { var item PromptRuleResponse var createdAt interface{} var updatedAt interface{} @@ -505,10 +556,10 @@ func (s *PromptRuleService) loadPromptRuleDetail(ctx context.Context, tenantID, SELECT pr.id, pr.group_id, pr.name, pr.prompt_content, pr.scene, pr.default_tone, pr.default_word_count, pr.status, pr.created_at, pr.updated_at, - (SELECT COUNT(*) FROM articles WHERE prompt_rule_id = pr.id AND deleted_at IS NULL)::INT + (SELECT COUNT(*) FROM articles WHERE prompt_rule_id = pr.id AND brand_id = pr.brand_id AND deleted_at IS NULL)::INT FROM prompt_rules pr - WHERE pr.id = $1 AND pr.tenant_id = $2 AND pr.deleted_at IS NULL - `, ruleID, tenantID).Scan(&item.ID, &item.GroupID, &item.Name, &item.PromptContent, + WHERE pr.id = $1 AND pr.tenant_id = $2 AND pr.brand_id = $3 AND pr.deleted_at IS NULL + `, ruleID, tenantID, brandID).Scan(&item.ID, &item.GroupID, &item.Name, &item.PromptContent, &item.Scene, &item.DefaultTone, &item.DefaultWordCount, &item.Status, &createdAt, &updatedAt, &articleCount) if err != nil { @@ -530,12 +581,12 @@ func (s *PromptRuleService) loadPromptRuleDetail(ctx context.Context, tenantID, return &item, true, nil } -func (s *PromptRuleService) loadPromptRuleSimple(ctx context.Context, tenantID int64) ([]PromptRuleSimple, error) { +func (s *PromptRuleService) loadPromptRuleSimple(ctx context.Context, tenantID, brandID int64) ([]PromptRuleSimple, error) { rows, err := s.pool.Query(ctx, ` SELECT id, name FROM prompt_rules - WHERE tenant_id = $1 AND deleted_at IS NULL AND status = 'enabled' + WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL AND status = 'enabled' ORDER BY name - `, tenantID) + `, tenantID, brandID) if err != nil { return nil, response.ErrInternal(50010, "query_failed", "failed to list rules") } @@ -552,6 +603,30 @@ func (s *PromptRuleService) loadPromptRuleSimple(ctx context.Context, tenantID i return items, nil } +func (s *PromptRuleService) validatePromptRuleGroup(ctx context.Context, tenantID, brandID int64, groupID *int64) error { + if groupID == nil || *groupID <= 0 { + return nil + } + + var exists bool + if err := s.pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 + FROM prompt_rule_groups + WHERE id = $1 + AND tenant_id = $2 + AND brand_id = $3 + AND deleted_at IS NULL + ) + `, *groupID, tenantID, brandID).Scan(&exists); err != nil { + return response.ErrInternal(50010, "group_query_failed", "failed to validate prompt rule group") + } + if !exists { + return response.ErrNotFound(40430, "group_not_found", "prompt rule group not found") + } + return nil +} + func (s *PromptRuleService) validatePromptRuleKnowledgeGroups( ctx context.Context, tenantID int64, diff --git a/server/internal/tenant/app/schedule_task_service.go b/server/internal/tenant/app/schedule_task_service.go index 73aa0f5..af9679b 100644 --- a/server/internal/tenant/app/schedule_task_service.go +++ b/server/internal/tenant/app/schedule_task_service.go @@ -139,7 +139,7 @@ func (s *ScheduleTaskService) Create(ctx context.Context, req ScheduleTaskReques if err := validateScheduleCronExpr(req.CronExpr); err != nil { return nil, err } - if err := s.ensurePromptRuleExists(ctx, actor.TenantID, req.PromptRuleID); err != nil { + if err := s.ensurePromptRuleExists(ctx, actor.TenantID, brandID, req.PromptRuleID); err != nil { return nil, err } generateCount, err := normalizeScheduleGenerateCount(req.GenerateCount) @@ -242,7 +242,7 @@ func (s *ScheduleTaskService) Update(ctx context.Context, id int64, req Schedule if err := validateScheduleCronExpr(req.CronExpr); err != nil { return err } - if err := s.ensurePromptRuleExists(ctx, actor.TenantID, req.PromptRuleID); err != nil { + if err := s.ensurePromptRuleExists(ctx, actor.TenantID, brandID, req.PromptRuleID); err != nil { return err } generateCount, err := normalizeScheduleGenerateCount(req.GenerateCount) @@ -637,11 +637,11 @@ func scanScheduleTaskResponse(scanner interface { return item, nil } -func (s *ScheduleTaskService) ensurePromptRuleExists(ctx context.Context, tenantID, promptRuleID int64) error { +func (s *ScheduleTaskService) ensurePromptRuleExists(ctx context.Context, tenantID, brandID, promptRuleID int64) error { var exists bool _ = s.pool.QueryRow(ctx, ` - SELECT EXISTS(SELECT 1 FROM prompt_rules WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL) - `, promptRuleID, tenantID).Scan(&exists) + SELECT EXISTS(SELECT 1 FROM prompt_rules WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL) + `, promptRuleID, tenantID, brandID).Scan(&exists) if !exists { return response.ErrBadRequest(40001, "invalid_rule", "prompt rule not found or not active") } diff --git a/server/internal/tenant/repository/auth_repo.go b/server/internal/tenant/repository/auth_repo.go index fd276c2..854ec8f 100644 --- a/server/internal/tenant/repository/auth_repo.go +++ b/server/internal/tenant/repository/auth_repo.go @@ -34,6 +34,7 @@ type AuthRepository interface { GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) GetUserByLoginIdentifier(ctx context.Context, identifier string) (*UserRecord, error) GetUserByID(ctx context.Context, id int64) (*UserRecord, error) + UpdateUserPassword(ctx context.Context, id int64, passwordHash string) error GetTenantMembership(ctx context.Context, userID int64) (*MembershipRecord, error) GetTenantMembershipByTenantAndUser(ctx context.Context, tenantID, userID int64) (*MembershipRecord, error) } @@ -103,6 +104,13 @@ func (r *authRepository) GetUserByID(ctx context.Context, id int64) (*UserRecord }, nil } +func (r *authRepository) UpdateUserPassword(ctx context.Context, id int64, passwordHash string) error { + return r.q.UpdateUserPassword(ctx, generated.UpdateUserPasswordParams{ + ID: id, + PasswordHash: passwordHash, + }) +} + func (r *authRepository) GetTenantMembership(ctx context.Context, userID int64) (*MembershipRecord, error) { row, err := r.q.GetTenantMembership(ctx, userID) if err != nil { diff --git a/server/internal/tenant/repository/generated/auth.sql.go b/server/internal/tenant/repository/generated/auth.sql.go index 64b2017..472dbc6 100644 --- a/server/internal/tenant/repository/generated/auth.sql.go +++ b/server/internal/tenant/repository/generated/auth.sql.go @@ -198,3 +198,20 @@ func (q *Queries) GetUserByLoginIdentifier(ctx context.Context, loginIdentifier ) return i, err } + +const updateUserPassword = `-- name: UpdateUserPassword :exec +UPDATE users +SET password_hash = $1, + updated_at = NOW() +WHERE id = $2 AND deleted_at IS NULL +` + +type UpdateUserPasswordParams struct { + PasswordHash string `json:"password_hash"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error { + _, err := q.db.Exec(ctx, updateUserPassword, arg.PasswordHash, arg.ID) + return err +} diff --git a/server/internal/tenant/repository/generated/models.go b/server/internal/tenant/repository/generated/models.go index d452e5d..f3e493c 100644 --- a/server/internal/tenant/repository/generated/models.go +++ b/server/internal/tenant/repository/generated/models.go @@ -691,6 +691,7 @@ type PromptRule struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + BrandID int64 `json:"brand_id"` } type PromptRuleGroup struct { @@ -701,6 +702,7 @@ type PromptRuleGroup struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + BrandID int64 `json:"brand_id"` } type PromptRuleKnowledgeGroup struct { diff --git a/server/internal/tenant/repository/generated/prompt_rule.sql.go b/server/internal/tenant/repository/generated/prompt_rule.sql.go index 76674f7..951a2f1 100644 --- a/server/internal/tenant/repository/generated/prompt_rule.sql.go +++ b/server/internal/tenant/repository/generated/prompt_rule.sql.go @@ -15,14 +15,16 @@ const countPromptRules = `-- name: CountPromptRules :one SELECT COUNT(*) FROM prompt_rules WHERE tenant_id = $1 + AND brand_id = $2 AND deleted_at IS NULL - AND ($2::bigint IS NULL OR group_id = $2) - AND ($3::text IS NULL OR status = $3) - AND ($4::text IS NULL OR name ILIKE '%' || $4 || '%') + AND ($3::bigint IS NULL OR group_id = $3) + AND ($4::text IS NULL OR status = $4) + AND ($5::text IS NULL OR name ILIKE '%' || $5 || '%') ` type CountPromptRulesParams struct { TenantID int64 `json:"tenant_id"` + BrandID int64 `json:"brand_id"` GroupID pgtype.Int8 `json:"group_id"` Status pgtype.Text `json:"status"` Keyword pgtype.Text `json:"keyword"` @@ -31,6 +33,7 @@ type CountPromptRulesParams struct { func (q *Queries) CountPromptRules(ctx context.Context, arg CountPromptRulesParams) (int64, error) { row := q.db.QueryRow(ctx, countPromptRules, arg.TenantID, + arg.BrandID, arg.GroupID, arg.Status, arg.Keyword, @@ -44,26 +47,33 @@ const countPromptRulesUngrouped = `-- name: CountPromptRulesUngrouped :one SELECT COUNT(*) FROM prompt_rules WHERE tenant_id = $1 + AND brand_id = $2 AND deleted_at IS NULL AND group_id IS NULL ` -func (q *Queries) CountPromptRulesUngrouped(ctx context.Context, tenantID int64) (int64, error) { - row := q.db.QueryRow(ctx, countPromptRulesUngrouped, tenantID) +type CountPromptRulesUngroupedParams struct { + TenantID int64 `json:"tenant_id"` + BrandID int64 `json:"brand_id"` +} + +func (q *Queries) CountPromptRulesUngrouped(ctx context.Context, arg CountPromptRulesUngroupedParams) (int64, error) { + row := q.db.QueryRow(ctx, countPromptRulesUngrouped, arg.TenantID, arg.BrandID) var count int64 err := row.Scan(&count) return count, err } const createPromptRule = `-- name: CreatePromptRule :one -INSERT INTO prompt_rules (tenant_id, group_id, name, prompt_content, scene, default_tone, default_word_count) -VALUES ($1, $2, $3, $4, - $5, $6, $7) +INSERT INTO prompt_rules (tenant_id, brand_id, group_id, name, prompt_content, scene, default_tone, default_word_count) +VALUES ($1, $2, $3, $4, $5, + $6, $7, $8) RETURNING id, created_at ` type CreatePromptRuleParams struct { TenantID int64 `json:"tenant_id"` + BrandID int64 `json:"brand_id"` GroupID pgtype.Int8 `json:"group_id"` Name string `json:"name"` PromptContent string `json:"prompt_content"` @@ -80,6 +90,7 @@ type CreatePromptRuleRow struct { func (q *Queries) CreatePromptRule(ctx context.Context, arg CreatePromptRuleParams) (CreatePromptRuleRow, error) { row := q.db.QueryRow(ctx, createPromptRule, arg.TenantID, + arg.BrandID, arg.GroupID, arg.Name, arg.PromptContent, @@ -93,13 +104,14 @@ func (q *Queries) CreatePromptRule(ctx context.Context, arg CreatePromptRulePara } const createPromptRuleGroup = `-- name: CreatePromptRuleGroup :one -INSERT INTO prompt_rule_groups (tenant_id, name, sort_order) -VALUES ($1, $2, $3) +INSERT INTO prompt_rule_groups (tenant_id, brand_id, name, sort_order) +VALUES ($1, $2, $3, $4) RETURNING id, created_at ` type CreatePromptRuleGroupParams struct { TenantID int64 `json:"tenant_id"` + BrandID int64 `json:"brand_id"` Name string `json:"name"` SortOrder int32 `json:"sort_order"` } @@ -110,7 +122,12 @@ type CreatePromptRuleGroupRow struct { } func (q *Queries) CreatePromptRuleGroup(ctx context.Context, arg CreatePromptRuleGroupParams) (CreatePromptRuleGroupRow, error) { - row := q.db.QueryRow(ctx, createPromptRuleGroup, arg.TenantID, arg.Name, arg.SortOrder) + row := q.db.QueryRow(ctx, createPromptRuleGroup, + arg.TenantID, + arg.BrandID, + arg.Name, + arg.SortOrder, + ) var i CreatePromptRuleGroupRow err := row.Scan(&i.ID, &i.CreatedAt) return i, err @@ -121,12 +138,13 @@ SELECT id, tenant_id, group_id, name, prompt_content, scene, default_tone, default_word_count, status, created_at, updated_at FROM prompt_rules -WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL +WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL ` type GetPromptRuleByIDParams struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` + BrandID int64 `json:"brand_id"` } type GetPromptRuleByIDRow struct { @@ -144,7 +162,7 @@ type GetPromptRuleByIDRow struct { } func (q *Queries) GetPromptRuleByID(ctx context.Context, arg GetPromptRuleByIDParams) (GetPromptRuleByIDRow, error) { - row := q.db.QueryRow(ctx, getPromptRuleByID, arg.ID, arg.TenantID) + row := q.db.QueryRow(ctx, getPromptRuleByID, arg.ID, arg.TenantID, arg.BrandID) var i GetPromptRuleByIDRow err := row.Scan( &i.ID, @@ -165,18 +183,23 @@ func (q *Queries) GetPromptRuleByID(ctx context.Context, arg GetPromptRuleByIDPa const listAllPromptRulesSimple = `-- name: ListAllPromptRulesSimple :many SELECT id, name, status FROM prompt_rules -WHERE tenant_id = $1 AND deleted_at IS NULL AND status = 'enabled' +WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL AND status = 'enabled' ORDER BY name ` +type ListAllPromptRulesSimpleParams struct { + TenantID int64 `json:"tenant_id"` + BrandID int64 `json:"brand_id"` +} + type ListAllPromptRulesSimpleRow struct { ID int64 `json:"id"` Name string `json:"name"` Status string `json:"status"` } -func (q *Queries) ListAllPromptRulesSimple(ctx context.Context, tenantID int64) ([]ListAllPromptRulesSimpleRow, error) { - rows, err := q.db.Query(ctx, listAllPromptRulesSimple, tenantID) +func (q *Queries) ListAllPromptRulesSimple(ctx context.Context, arg ListAllPromptRulesSimpleParams) ([]ListAllPromptRulesSimpleRow, error) { + rows, err := q.db.Query(ctx, listAllPromptRulesSimple, arg.TenantID, arg.BrandID) if err != nil { return nil, err } @@ -199,10 +222,15 @@ const listPromptRuleGroups = `-- name: ListPromptRuleGroups :many SELECT id, tenant_id, name, sort_order, created_at, updated_at FROM prompt_rule_groups -WHERE tenant_id = $1 AND deleted_at IS NULL +WHERE tenant_id = $1 AND brand_id = $2 AND deleted_at IS NULL ORDER BY sort_order, created_at ` +type ListPromptRuleGroupsParams struct { + TenantID int64 `json:"tenant_id"` + BrandID int64 `json:"brand_id"` +} + type ListPromptRuleGroupsRow struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` @@ -215,8 +243,8 @@ type ListPromptRuleGroupsRow struct { // ============================================================ // Prompt Rule Groups // ============================================================ -func (q *Queries) ListPromptRuleGroups(ctx context.Context, tenantID int64) ([]ListPromptRuleGroupsRow, error) { - rows, err := q.db.Query(ctx, listPromptRuleGroups, tenantID) +func (q *Queries) ListPromptRuleGroups(ctx context.Context, arg ListPromptRuleGroupsParams) ([]ListPromptRuleGroupsRow, error) { + rows, err := q.db.Query(ctx, listPromptRuleGroups, arg.TenantID, arg.BrandID) if err != nil { return nil, err } @@ -249,19 +277,21 @@ SELECT pr.id, pr.tenant_id, pr.group_id, pr.name, pr.prompt_content, pr.created_at, pr.updated_at, COUNT(a.id)::INT AS article_count FROM prompt_rules pr -LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.deleted_at IS NULL +LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.brand_id = pr.brand_id AND a.deleted_at IS NULL WHERE pr.tenant_id = $1 + AND pr.brand_id = $2 AND pr.deleted_at IS NULL - AND ($2::bigint IS NULL OR pr.group_id = $2) - AND ($3::text IS NULL OR pr.status = $3) - AND ($4::text IS NULL OR pr.name ILIKE '%' || $4 || '%') + AND ($3::bigint IS NULL OR pr.group_id = $3) + AND ($4::text IS NULL OR pr.status = $4) + AND ($5::text IS NULL OR pr.name ILIKE '%' || $5 || '%') GROUP BY pr.id ORDER BY pr.created_at DESC -LIMIT $6 OFFSET $5 +LIMIT $7 OFFSET $6 ` type ListPromptRulesParams struct { TenantID int64 `json:"tenant_id"` + BrandID int64 `json:"brand_id"` GroupID pgtype.Int8 `json:"group_id"` Status pgtype.Text `json:"status"` Keyword pgtype.Text `json:"keyword"` @@ -290,6 +320,7 @@ type ListPromptRulesRow struct { func (q *Queries) ListPromptRules(ctx context.Context, arg ListPromptRulesParams) ([]ListPromptRulesRow, error) { rows, err := q.db.Query(ctx, listPromptRules, arg.TenantID, + arg.BrandID, arg.GroupID, arg.Status, arg.Keyword, @@ -333,17 +364,19 @@ SELECT pr.id, pr.tenant_id, pr.group_id, pr.name, pr.prompt_content, pr.created_at, pr.updated_at, COUNT(a.id)::INT AS article_count FROM prompt_rules pr -LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.deleted_at IS NULL +LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.brand_id = pr.brand_id AND a.deleted_at IS NULL WHERE pr.tenant_id = $1 + AND pr.brand_id = $2 AND pr.deleted_at IS NULL AND pr.group_id IS NULL GROUP BY pr.id ORDER BY pr.created_at DESC -LIMIT $3 OFFSET $2 +LIMIT $4 OFFSET $3 ` type ListPromptRulesUngroupedParams struct { TenantID int64 `json:"tenant_id"` + BrandID int64 `json:"brand_id"` PageOffset int32 `json:"page_offset"` PageSize int32 `json:"page_size"` } @@ -364,7 +397,12 @@ type ListPromptRulesUngroupedRow struct { } func (q *Queries) ListPromptRulesUngrouped(ctx context.Context, arg ListPromptRulesUngroupedParams) ([]ListPromptRulesUngroupedRow, error) { - rows, err := q.db.Query(ctx, listPromptRulesUngrouped, arg.TenantID, arg.PageOffset, arg.PageSize) + rows, err := q.db.Query(ctx, listPromptRulesUngrouped, + arg.TenantID, + arg.BrandID, + arg.PageOffset, + arg.PageSize, + ) if err != nil { return nil, err } @@ -398,31 +436,33 @@ func (q *Queries) ListPromptRulesUngrouped(ctx context.Context, arg ListPromptRu const softDeletePromptRule = `-- name: SoftDeletePromptRule :exec UPDATE prompt_rules SET deleted_at = NOW(), updated_at = NOW() -WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL +WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL ` type SoftDeletePromptRuleParams struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` + BrandID int64 `json:"brand_id"` } func (q *Queries) SoftDeletePromptRule(ctx context.Context, arg SoftDeletePromptRuleParams) error { - _, err := q.db.Exec(ctx, softDeletePromptRule, arg.ID, arg.TenantID) + _, err := q.db.Exec(ctx, softDeletePromptRule, arg.ID, arg.TenantID, arg.BrandID) return err } const softDeletePromptRuleGroup = `-- name: SoftDeletePromptRuleGroup :exec UPDATE prompt_rule_groups SET deleted_at = NOW(), updated_at = NOW() -WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL +WHERE id = $1 AND tenant_id = $2 AND brand_id = $3 AND deleted_at IS NULL ` type SoftDeletePromptRuleGroupParams struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` + BrandID int64 `json:"brand_id"` } func (q *Queries) SoftDeletePromptRuleGroup(ctx context.Context, arg SoftDeletePromptRuleGroupParams) error { - _, err := q.db.Exec(ctx, softDeletePromptRuleGroup, arg.ID, arg.TenantID) + _, err := q.db.Exec(ctx, softDeletePromptRuleGroup, arg.ID, arg.TenantID, arg.BrandID) return err } @@ -432,7 +472,7 @@ SET name = $1, group_id = $2, prompt_content = $3, scene = $4, default_tone = $5, default_word_count = $6, updated_at = NOW() -WHERE id = $7 AND tenant_id = $8 AND deleted_at IS NULL +WHERE id = $7 AND tenant_id = $8 AND brand_id = $9 AND deleted_at IS NULL ` type UpdatePromptRuleParams struct { @@ -444,6 +484,7 @@ type UpdatePromptRuleParams struct { DefaultWordCount pgtype.Int4 `json:"default_word_count"` ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` + BrandID int64 `json:"brand_id"` } func (q *Queries) UpdatePromptRule(ctx context.Context, arg UpdatePromptRuleParams) error { @@ -456,13 +497,14 @@ func (q *Queries) UpdatePromptRule(ctx context.Context, arg UpdatePromptRulePara arg.DefaultWordCount, arg.ID, arg.TenantID, + arg.BrandID, ) return err } const updatePromptRuleGroup = `-- name: UpdatePromptRuleGroup :exec UPDATE prompt_rule_groups SET name = $1, sort_order = $2, updated_at = NOW() -WHERE id = $3 AND tenant_id = $4 AND deleted_at IS NULL +WHERE id = $3 AND tenant_id = $4 AND brand_id = $5 AND deleted_at IS NULL ` type UpdatePromptRuleGroupParams struct { @@ -470,6 +512,7 @@ type UpdatePromptRuleGroupParams struct { SortOrder int32 `json:"sort_order"` ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` + BrandID int64 `json:"brand_id"` } func (q *Queries) UpdatePromptRuleGroup(ctx context.Context, arg UpdatePromptRuleGroupParams) error { @@ -478,22 +521,29 @@ func (q *Queries) UpdatePromptRuleGroup(ctx context.Context, arg UpdatePromptRul arg.SortOrder, arg.ID, arg.TenantID, + arg.BrandID, ) return err } const updatePromptRuleStatus = `-- name: UpdatePromptRuleStatus :exec UPDATE prompt_rules SET status = $1, updated_at = NOW() -WHERE id = $2 AND tenant_id = $3 AND deleted_at IS NULL +WHERE id = $2 AND tenant_id = $3 AND brand_id = $4 AND deleted_at IS NULL ` type UpdatePromptRuleStatusParams struct { Status string `json:"status"` ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` + BrandID int64 `json:"brand_id"` } func (q *Queries) UpdatePromptRuleStatus(ctx context.Context, arg UpdatePromptRuleStatusParams) error { - _, err := q.db.Exec(ctx, updatePromptRuleStatus, arg.Status, arg.ID, arg.TenantID) + _, err := q.db.Exec(ctx, updatePromptRuleStatus, + arg.Status, + arg.ID, + arg.TenantID, + arg.BrandID, + ) return err } diff --git a/server/internal/tenant/repository/generated/querier.go b/server/internal/tenant/repository/generated/querier.go index 2cd5bbb..36e7b98 100644 --- a/server/internal/tenant/repository/generated/querier.go +++ b/server/internal/tenant/repository/generated/querier.go @@ -24,7 +24,7 @@ type Querier interface { CountImageAssets(ctx context.Context, arg CountImageAssetsParams) (int32, error) CountImageReferences(ctx context.Context, arg CountImageReferencesParams) (int32, error) CountPromptRules(ctx context.Context, arg CountPromptRulesParams) (int64, error) - CountPromptRulesUngrouped(ctx context.Context, tenantID int64) (int64, error) + CountPromptRulesUngrouped(ctx context.Context, arg CountPromptRulesUngroupedParams) (int64, error) CountPublishedArticlesByTenantAndBrand(ctx context.Context, arg CountPublishedArticlesByTenantAndBrandParams) (int64, error) CountScheduleTasks(ctx context.Context, arg CountScheduleTasksParams) (int64, error) CountUncategorizedImages(ctx context.Context, tenantID int64) (int32, error) @@ -111,7 +111,7 @@ type Querier interface { // Cross-tenant read: used by platform admin for subscription fan-out. // Excludes expired subscriptions so new prompts are not granted to them. ListActiveSubscribersForPackage(ctx context.Context, packageID int64) ([]KolSubscription, error) - ListAllPromptRulesSimple(ctx context.Context, tenantID int64) ([]ListAllPromptRulesSimpleRow, error) + ListAllPromptRulesSimple(ctx context.Context, arg ListAllPromptRulesSimpleParams) ([]ListAllPromptRulesSimpleRow, error) ListArticles(ctx context.Context, arg ListArticlesParams) ([]ListArticlesRow, error) ListBrands(ctx context.Context, tenantID int64) ([]ListBrandsRow, error) ListCompetitors(ctx context.Context, arg ListCompetitorsParams) ([]ListCompetitorsRow, error) @@ -132,7 +132,7 @@ type Querier interface { // ============================================================ // Prompt Rule Groups // ============================================================ - ListPromptRuleGroups(ctx context.Context, tenantID int64) ([]ListPromptRuleGroupsRow, error) + ListPromptRuleGroups(ctx context.Context, arg ListPromptRuleGroupsParams) ([]ListPromptRuleGroupsRow, error) // ============================================================ // Prompt Rules // ============================================================ @@ -208,6 +208,7 @@ type Querier interface { UpdateScheduleTask(ctx context.Context, arg UpdateScheduleTaskParams) error UpdateScheduleTaskStatus(ctx context.Context, arg UpdateScheduleTaskStatusParams) error UpdateTaskRecordStatus(ctx context.Context, arg UpdateTaskRecordStatusParams) error + UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error UpsertDesktopAccount(ctx context.Context, arg UpsertDesktopAccountParams) (UpsertDesktopAccountRow, error) UpsertImageReference(ctx context.Context, arg UpsertImageReferenceParams) error } diff --git a/server/internal/tenant/repository/queries/auth.sql b/server/internal/tenant/repository/queries/auth.sql index 634af14..5f8c21c 100644 --- a/server/internal/tenant/repository/queries/auth.sql +++ b/server/internal/tenant/repository/queries/auth.sql @@ -14,6 +14,12 @@ SELECT id, email, phone, password_hash, name, avatar_url, status, created_at, up FROM users WHERE id = sqlc.arg(id) AND deleted_at IS NULL; +-- name: UpdateUserPassword :exec +UPDATE users +SET password_hash = sqlc.arg(password_hash), + updated_at = NOW() +WHERE id = sqlc.arg(id) AND deleted_at IS NULL; + -- name: GetTenantMembership :one SELECT tm.id, tm.tenant_id, diff --git a/server/internal/tenant/repository/queries/prompt_rule.sql b/server/internal/tenant/repository/queries/prompt_rule.sql index 41a20cb..754b68f 100644 --- a/server/internal/tenant/repository/queries/prompt_rule.sql +++ b/server/internal/tenant/repository/queries/prompt_rule.sql @@ -5,21 +5,21 @@ -- name: ListPromptRuleGroups :many SELECT id, tenant_id, name, sort_order, created_at, updated_at FROM prompt_rule_groups -WHERE tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL +WHERE tenant_id = sqlc.arg(tenant_id) AND brand_id = sqlc.arg(brand_id) AND deleted_at IS NULL ORDER BY sort_order, created_at; -- name: CreatePromptRuleGroup :one -INSERT INTO prompt_rule_groups (tenant_id, name, sort_order) -VALUES (sqlc.arg(tenant_id), sqlc.arg(name), sqlc.arg(sort_order)) +INSERT INTO prompt_rule_groups (tenant_id, brand_id, name, sort_order) +VALUES (sqlc.arg(tenant_id), sqlc.arg(brand_id), sqlc.arg(name), sqlc.arg(sort_order)) RETURNING id, created_at; -- name: UpdatePromptRuleGroup :exec UPDATE prompt_rule_groups SET name = sqlc.arg(name), sort_order = sqlc.arg(sort_order), updated_at = NOW() -WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL; +WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND brand_id = sqlc.arg(brand_id) AND deleted_at IS NULL; -- name: SoftDeletePromptRuleGroup :exec UPDATE prompt_rule_groups SET deleted_at = NOW(), updated_at = NOW() -WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL; +WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND brand_id = sqlc.arg(brand_id) AND deleted_at IS NULL; -- ============================================================ -- Prompt Rules @@ -31,8 +31,9 @@ SELECT pr.id, pr.tenant_id, pr.group_id, pr.name, pr.prompt_content, pr.created_at, pr.updated_at, COUNT(a.id)::INT AS article_count FROM prompt_rules pr -LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.deleted_at IS NULL +LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.brand_id = pr.brand_id AND a.deleted_at IS NULL WHERE pr.tenant_id = sqlc.arg(tenant_id) + AND pr.brand_id = sqlc.arg(brand_id) AND pr.deleted_at IS NULL AND (sqlc.narg(group_id)::bigint IS NULL OR pr.group_id = sqlc.narg(group_id)) AND (sqlc.narg(status)::text IS NULL OR pr.status = sqlc.narg(status)) @@ -45,6 +46,7 @@ LIMIT sqlc.arg(page_size) OFFSET sqlc.arg(page_offset); SELECT COUNT(*) FROM prompt_rules WHERE tenant_id = sqlc.arg(tenant_id) + AND brand_id = sqlc.arg(brand_id) AND deleted_at IS NULL AND (sqlc.narg(group_id)::bigint IS NULL OR group_id = sqlc.narg(group_id)) AND (sqlc.narg(status)::text IS NULL OR status = sqlc.narg(status)) @@ -56,8 +58,9 @@ SELECT pr.id, pr.tenant_id, pr.group_id, pr.name, pr.prompt_content, pr.created_at, pr.updated_at, COUNT(a.id)::INT AS article_count FROM prompt_rules pr -LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.deleted_at IS NULL +LEFT JOIN articles a ON a.prompt_rule_id = pr.id AND a.brand_id = pr.brand_id AND a.deleted_at IS NULL WHERE pr.tenant_id = sqlc.arg(tenant_id) + AND pr.brand_id = sqlc.arg(brand_id) AND pr.deleted_at IS NULL AND pr.group_id IS NULL GROUP BY pr.id @@ -68,6 +71,7 @@ LIMIT sqlc.arg(page_size) OFFSET sqlc.arg(page_offset); SELECT COUNT(*) FROM prompt_rules WHERE tenant_id = sqlc.arg(tenant_id) + AND brand_id = sqlc.arg(brand_id) AND deleted_at IS NULL AND group_id IS NULL; @@ -76,11 +80,11 @@ SELECT id, tenant_id, group_id, name, prompt_content, scene, default_tone, default_word_count, status, created_at, updated_at FROM prompt_rules -WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL; +WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND brand_id = sqlc.arg(brand_id) AND deleted_at IS NULL; -- name: CreatePromptRule :one -INSERT INTO prompt_rules (tenant_id, group_id, name, prompt_content, scene, default_tone, default_word_count) -VALUES (sqlc.arg(tenant_id), sqlc.narg(group_id), sqlc.arg(name), sqlc.arg(prompt_content), +INSERT INTO prompt_rules (tenant_id, brand_id, group_id, name, prompt_content, scene, default_tone, default_word_count) +VALUES (sqlc.arg(tenant_id), sqlc.arg(brand_id), sqlc.narg(group_id), sqlc.arg(name), sqlc.arg(prompt_content), sqlc.narg(scene), sqlc.narg(default_tone), sqlc.narg(default_word_count)) RETURNING id, created_at; @@ -90,18 +94,18 @@ SET name = sqlc.arg(name), group_id = sqlc.narg(group_id), prompt_content = sqlc.arg(prompt_content), scene = sqlc.narg(scene), default_tone = sqlc.narg(default_tone), default_word_count = sqlc.narg(default_word_count), updated_at = NOW() -WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL; +WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND brand_id = sqlc.arg(brand_id) AND deleted_at IS NULL; -- name: UpdatePromptRuleStatus :exec UPDATE prompt_rules SET status = sqlc.arg(status), updated_at = NOW() -WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL; +WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND brand_id = sqlc.arg(brand_id) AND deleted_at IS NULL; -- name: SoftDeletePromptRule :exec UPDATE prompt_rules SET deleted_at = NOW(), updated_at = NOW() -WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL; +WHERE id = sqlc.arg(id) AND tenant_id = sqlc.arg(tenant_id) AND brand_id = sqlc.arg(brand_id) AND deleted_at IS NULL; -- name: ListAllPromptRulesSimple :many SELECT id, name, status FROM prompt_rules -WHERE tenant_id = sqlc.arg(tenant_id) AND deleted_at IS NULL AND status = 'enabled' +WHERE tenant_id = sqlc.arg(tenant_id) AND brand_id = sqlc.arg(brand_id) AND deleted_at IS NULL AND status = 'enabled' ORDER BY name; diff --git a/server/internal/tenant/transport/auth_handler.go b/server/internal/tenant/transport/auth_handler.go index c987dad..5d70c56 100644 --- a/server/internal/tenant/transport/auth_handler.go +++ b/server/internal/tenant/transport/auth_handler.go @@ -81,6 +81,25 @@ func (h *AuthHandler) Me(c *gin.Context) { response.Success(c, resp) } +func (h *AuthHandler) ChangePassword(c *gin.Context) { + actor, ok := auth.ActorFromCtx(c.Request.Context()) + if !ok { + response.Error(c, response.ErrUnauthorized(40100, "not_authenticated", "not authenticated")) + return + } + + var req app.ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error())) + return + } + if err := h.svc.ChangePassword(c.Request.Context(), actor, req); err != nil { + response.Error(c, err) + return + } + response.Success(c, gin.H{"ok": true}) +} + func (h *AuthHandler) Logout(c *gin.Context) { header := c.GetHeader("Authorization") parts := strings.SplitN(header, " ", 2) diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index 156f420..9d53b89 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -29,6 +29,7 @@ func RegisterRoutes(app *bootstrap.App) { protected.Use(middleware.BrandScope(app.DB)) protected.GET("/auth/me", authHandler.Me) + protected.POST("/auth/password", authHandler.ChangePassword) protected.POST("/auth/logout", authHandler.Logout) desktopClientHandler := NewDesktopClientHandler(app) @@ -229,6 +230,7 @@ func RegisterRoutes(app *bootstrap.App) { // Prompt Rules promptRules := tenantProtected.Group("/prompt-rules") + promptRules.Use(middleware.RequireCurrentBrand()) prHandler := NewPromptRuleHandler(app) promptRules.GET("", prHandler.List) promptRules.GET("/simple", prHandler.ListSimple) diff --git a/server/internal/tenant/transport/router_test.go b/server/internal/tenant/transport/router_test.go index aedeb5e..4b2cd4b 100644 --- a/server/internal/tenant/transport/router_test.go +++ b/server/internal/tenant/transport/router_test.go @@ -24,6 +24,7 @@ func TestProtectedRoutes_RequireAuth(t *testing.T) { path string }{ {http.MethodGet, "/api/auth/me"}, + {http.MethodPost, "/api/auth/password"}, {http.MethodPost, "/api/auth/logout"}, {http.MethodPost, "/api/desktop/clients/register"}, {http.MethodGet, "/api/tenant/accounts"}, diff --git a/server/migrations/20260520150000_scope_prompt_rules_to_brand.down.sql b/server/migrations/20260520150000_scope_prompt_rules_to_brand.down.sql new file mode 100644 index 0000000..c1672ed --- /dev/null +++ b/server/migrations/20260520150000_scope_prompt_rules_to_brand.down.sql @@ -0,0 +1,25 @@ +DROP INDEX IF EXISTS idx_prompt_rule_tenant_brand_status; +DROP INDEX IF EXISTS idx_prompt_rule_tenant_brand_created; +DROP INDEX IF EXISTS idx_prompt_rule_group_tenant_brand; + +CREATE INDEX IF NOT EXISTS idx_prompt_rule_group_tenant + ON prompt_rule_groups(tenant_id, sort_order); + +CREATE INDEX IF NOT EXISTS idx_prompt_rule_tenant + ON prompt_rules(tenant_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_prompt_rule_tenant_status + ON prompt_rules(tenant_id, status) + WHERE deleted_at IS NULL; + +ALTER TABLE prompt_rules + ALTER COLUMN brand_id DROP NOT NULL; + +ALTER TABLE prompt_rule_groups + ALTER COLUMN brand_id DROP NOT NULL; + +ALTER TABLE prompt_rules + DROP COLUMN IF EXISTS brand_id; + +ALTER TABLE prompt_rule_groups + DROP COLUMN IF EXISTS brand_id; diff --git a/server/migrations/20260520150000_scope_prompt_rules_to_brand.up.sql b/server/migrations/20260520150000_scope_prompt_rules_to_brand.up.sql new file mode 100644 index 0000000..baa8289 --- /dev/null +++ b/server/migrations/20260520150000_scope_prompt_rules_to_brand.up.sql @@ -0,0 +1,60 @@ +ALTER TABLE prompt_rule_groups + ADD COLUMN IF NOT EXISTS brand_id BIGINT REFERENCES brands(id); + +ALTER TABLE prompt_rules + ADD COLUMN IF NOT EXISTS brand_id BIGINT REFERENCES brands(id); + +INSERT INTO brands (tenant_id, name, description, status) +SELECT tenant_id, '未归属', '系统迁移生成,用于存放未归属公司的历史 Prompt 规则。', 'active' +FROM ( + SELECT tenant_id FROM prompt_rule_groups WHERE brand_id IS NULL + UNION + SELECT tenant_id FROM prompt_rules WHERE brand_id IS NULL +) tenants_with_prompt_rules +WHERE NOT EXISTS ( + SELECT 1 + FROM brands b + WHERE b.tenant_id = tenants_with_prompt_rules.tenant_id + AND b.name = '未归属' + AND b.deleted_at IS NULL +); + +UPDATE prompt_rule_groups prg +SET brand_id = b.id, + updated_at = NOW() +FROM brands b +WHERE b.tenant_id = prg.tenant_id + AND b.name = '未归属' + AND b.deleted_at IS NULL + AND prg.brand_id IS NULL; + +UPDATE prompt_rules pr +SET brand_id = b.id, + updated_at = NOW() +FROM brands b +WHERE b.tenant_id = pr.tenant_id + AND b.name = '未归属' + AND b.deleted_at IS NULL + AND pr.brand_id IS NULL; + +ALTER TABLE prompt_rule_groups + ALTER COLUMN brand_id SET NOT NULL; + +ALTER TABLE prompt_rules + ALTER COLUMN brand_id SET NOT NULL; + +DROP INDEX IF EXISTS idx_prompt_rule_group_tenant; +DROP INDEX IF EXISTS idx_prompt_rule_tenant; +DROP INDEX IF EXISTS idx_prompt_rule_tenant_status; + +CREATE INDEX IF NOT EXISTS idx_prompt_rule_group_tenant_brand + ON prompt_rule_groups(tenant_id, brand_id, sort_order, created_at) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_prompt_rule_tenant_brand_created + ON prompt_rules(tenant_id, brand_id, created_at DESC) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_prompt_rule_tenant_brand_status + ON prompt_rules(tenant_id, brand_id, status) + WHERE deleted_at IS NULL;