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
+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")
}