Scert/backend/internal/repository/project_member_changes.go

115 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
var ErrProjectMemberConflict = errors.New("项目成员角色已存在")
// 成员写入仍在单条语句内检查权限;审计与变更共用事务,任何一步失败都不留下成功变更。
func (r *SQLProjectMemberRepository) changeProjectRole(ctx context.Context, actorID, projectID, userID string, role domain.ProjectRole, grant bool) error {
if err := r.ensureDB(); err != nil {
return err
}
actorID, projectID, userID = strings.TrimSpace(actorID), strings.TrimSpace(projectID), strings.TrimSpace(userID)
if actorID == "" || projectID == "" || userID == "" || !role.Valid() {
return ErrProjectAccessDenied
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("开始项目成员变更事务失败:%w", err)
}
defer tx.Rollback()
if r.driver == "mysql" {
// 历史库可能使用非事务引擎,不能仅凭 BeginTx 成功就承诺审计和权限一起回滚。
var transactionalTables int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name IN ('project_members', 'audit_logs')
AND engine='InnoDB'`).Scan(&transactionalTables); err != nil {
return fmt.Errorf("检查项目成员审计存储失败:%w", err)
}
if transactionalTables != 2 {
return fmt.Errorf("项目成员和审计表必须使用 InnoDB,拒绝无法保证回滚的权限变更")
}
}
now := time.Now().UTC()
if err := r.writeProjectRole(ctx, tx, actorID, projectID, userID, role, grant, now); err != nil {
return err
}
action, message := "project_member.revoke", "撤销项目角色:"
if grant {
action, message = "project_member.grant", "授予项目角色:"
}
// 范围来自刚通过授权条件的实际成员变更,不在自我撤权后重新推测角色或审计范围。
// 不调用使用独立连接的审计方法,以免脱离事务或阻塞单连接 SQLite。
result, err := tx.ExecContext(ctx, Rebind(r.driver, `INSERT INTO audit_logs
(action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id)
VALUES (?, 'project_member', ?, 'success', ?, ?, ?, ?, '', ?)`),
action, userID, message+string(role), now, ScopeProject, projectID, actorID)
if err != nil {
return fmt.Errorf("保存项目成员变更审计失败:%w", err)
}
count, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("读取项目成员审计写入结果失败:%w", err)
}
if count != 1 {
return fmt.Errorf("项目成员审计未写入,已拒绝本次权限变更")
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交项目成员变更失败:%w", err)
}
return nil
}
func (r *SQLProjectMemberRepository) writeProjectRole(ctx context.Context, tx *sql.Tx, actorID, projectID, userID string, role domain.ProjectRole, grant bool, now time.Time) error {
query := `DELETE FROM project_members WHERE project_id = ? AND user_id = ? AND role = ? AND ` + projectManagerPredicate
args := []any{projectID, userID, role, actorID, projectID}
if grant {
query = `INSERT INTO project_members (project_id, user_id, role, created_at)
SELECT p.id, u.id, ?, ? FROM projects p JOIN users u ON u.id = ? AND u.status = 'active'
WHERE p.id = ? AND ` + projectManagerPredicate
args = []any{role, now, userID, projectID, actorID, projectID}
} else if r.driver == "mysql" {
// MySQL 撤销使用自连接,避免不兼容的同表删除子查询。
query = `DELETE target FROM project_members target
JOIN users a ON a.id = ? AND a.status = 'active'
LEFT JOIN project_members manager ON manager.project_id = target.project_id
AND manager.user_id = a.id AND manager.role = 'project_admin'
WHERE target.project_id = ? AND target.user_id = ? AND target.role = ?
AND (a.role = 'super_admin' OR (a.role IN ('admin', 'user') AND manager.user_id IS NOT NULL))`
args = []any{actorID, projectID, userID, role}
}
result, err := tx.ExecContext(ctx, Rebind(r.driver, query), args...)
if err != nil {
if grant && projectMemberUniqueViolation(err) {
return ErrProjectMemberConflict
}
return fmt.Errorf("变更项目角色失败:%w", err)
}
return projectMemberWriteResult(result)
}
// 只将成员写入的确定唯一键错误映射为冲突;审计触发器或其他约束失败不能伪装成重复授权。
func projectMemberUniqueViolation(err error) bool {
var sqliteCode interface{ Code() int }
if errors.As(err, &sqliteCode) {
return sqliteCode.Code() == 1555 || sqliteCode.Code() == 2067
}
var mysqlError *mysql.MySQLError
if errors.As(err, &mysqlError) {
return mysqlError.Number == 1062
}
var sqlState interface{ SQLState() string }
return errors.As(err, &sqlState) && sqlState.SQLState() == "23505"
}