feat(auth,prompt-rule): add password change with session revocation and scope prompt rules by brand
Frontend CI / Frontend (push) Successful in 3m8s
Backend CI / Backend (push) Successful in 14m48s

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:
2026-05-20 18:09:53 +08:00
parent 9f9e4f8e19
commit e045e00fbf
41 changed files with 1135 additions and 167 deletions
+1
View File
@@ -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
}
+6
View File
@@ -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",
+16
View File
@@ -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)
+11
View File
@@ -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 资源 ---
+90 -2
View File
@@ -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)
+18 -18
View File
@@ -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,
+138 -63
View File
@@ -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"},