feat(auth,prompt-rule): add password change with session revocation and scope prompt rules by brand
Add self-service password change that re-hashes the credential and bumps a per-user session version so all existing access/refresh tokens are rejected. Session version is tracked in Redis with an in-memory fallback and enforced in middleware and refresh. Scope prompt rules and rule groups to a brand: new brand_id column (migrated to a "未归属" fallback brand for existing rows), brand-filtered queries/indexes, and brand-aware admin-web tabs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 资源 ---
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user