Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
693 lines
23 KiB
Go
693 lines
23 KiB
Go
package accesscontrol
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/accesspolicy"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// ResourceFork records provenance independently of native deletion. A personal
|
|
// copy can never shed its no-export restriction by hiding its source.
|
|
type ResourceFork struct {
|
|
ID string `json:"id" gorm:"primaryKey"`
|
|
TenantID uint64 `json:"tenant_id" gorm:"uniqueIndex:resource_fork_owner,priority:1"`
|
|
OwnerID string `json:"owner_id" gorm:"uniqueIndex:resource_fork_owner,priority:2"`
|
|
Kind string `json:"kind" gorm:"uniqueIndex:resource_fork_owner,priority:3"`
|
|
SourceID string `json:"source_id" gorm:"uniqueIndex:resource_fork_owner,priority:4"`
|
|
PersonalID string `json:"personal_id"`
|
|
Name string `json:"name"`
|
|
BaseFingerprint string `json:"base_fingerprint"`
|
|
BaseSnapshot types.JSON `json:"base_snapshot" gorm:"type:jsonb"`
|
|
NoExport bool `json:"no_export"`
|
|
Hidden bool `json:"hidden" gorm:"-"`
|
|
Status string `json:"status" gorm:"not null;default:'ready'"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
func (ResourceFork) TableName() string { return "workbench_resource_forks" }
|
|
|
|
type ResourceSubmission struct {
|
|
ID string `json:"id" gorm:"primaryKey"`
|
|
TenantID uint64 `json:"tenant_id" gorm:"index"`
|
|
OwnerID string `json:"owner_id"`
|
|
OwnerName string `json:"owner_name" gorm:"-"`
|
|
ForkID string `json:"fork_id" gorm:"index"`
|
|
Kind string `json:"kind"`
|
|
SourceID string `json:"source_id"`
|
|
PersonalID string `json:"personal_id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
BaseFingerprint string `json:"base_fingerprint"`
|
|
PersonalFingerprint string `json:"personal_fingerprint"`
|
|
BaseSnapshot types.JSON `json:"base_snapshot" gorm:"type:jsonb"`
|
|
ProposedSnapshot types.JSON `json:"proposed_snapshot" gorm:"type:jsonb"`
|
|
Note string `json:"note"`
|
|
ReviewNote string `json:"review_note"`
|
|
ReviewerID string `json:"reviewer_id"`
|
|
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
|
SnapshotID string `json:"snapshot_id,omitempty"`
|
|
PublishedID string `json:"published_id,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func (ResourceSubmission) TableName() string { return "workbench_resource_submissions" }
|
|
|
|
type ResourceHidden struct {
|
|
TenantID uint64 `json:"tenant_id" gorm:"primaryKey"`
|
|
OwnerID string `json:"owner_id" gorm:"primaryKey"`
|
|
Kind string `json:"kind" gorm:"primaryKey"`
|
|
ResourceID string `json:"resource_id" gorm:"primaryKey"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func (ResourceHidden) TableName() string { return "workbench_resource_hidden" }
|
|
|
|
// Historical submissions remain read-only even when their personal copy was
|
|
// already deleted. Their source payload is retained only in the migration ledger.
|
|
type ResourceVersionHistory struct {
|
|
ID string `json:"id" gorm:"primaryKey"`
|
|
TenantID uint64 `json:"tenant_id"`
|
|
OwnerID string `json:"owner_id"`
|
|
OwnerName string `json:"owner_name" gorm:"-"`
|
|
Kind string `json:"kind"`
|
|
SourceID string `json:"source_id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
ProposedSnapshot types.JSON `json:"proposed_snapshot" gorm:"type:jsonb"`
|
|
ReviewNote string `json:"review_note"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
|
}
|
|
|
|
func (ResourceVersionHistory) TableName() string { return "workbench_resource_version_history" }
|
|
|
|
func versionPermission(ctx context.Context, kind string, write bool) error {
|
|
t, u := identity(ctx)
|
|
if t == 0 || u == "" || types.IsSyntheticUserID(u) {
|
|
return fmt.Errorf("%w: 请使用个人账号", errVersionDenied)
|
|
}
|
|
if _, ok := resourceTables[kind]; !ok {
|
|
return errInvalid
|
|
}
|
|
module := kind
|
|
if kind == "skills" {
|
|
module = "agents"
|
|
}
|
|
action := ".read"
|
|
if write {
|
|
action = ".write"
|
|
}
|
|
if !accesspolicy.Allowed(ctx, module+action) || (write && !administrator(ctx) && !types.TenantRoleFromContext(ctx).HasPermission(types.TenantRoleContributor)) {
|
|
return fmt.Errorf("%w: 缺少 %s 权限", errVersionDenied, module+action)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var errVersionDenied = fmt.Errorf("没有资源操作权限")
|
|
|
|
func (s *Service) RegisterResourceVersions(r *gin.RouterGroup) {
|
|
r.GET("/resource-versions", s.ListResourceVersions)
|
|
r.GET("/resource-versions/catalog", s.VersionCatalog)
|
|
r.GET("/resource-versions/forks/:id", s.GetResourceVersion)
|
|
// Keep explicit retired routes so stale clients cannot create or approve
|
|
// copies. Historical records remain available for inspection.
|
|
r.POST("/resource-versions/:kind/:id/fork", retiredResourceVersionMutation)
|
|
r.PUT("/resource-versions/:kind/:id/hidden", retiredResourceVersionMutation)
|
|
r.POST("/resource-versions/forks/:id/submit", retiredResourceVersionMutation)
|
|
r.PUT("/resource-versions/forks/:id", retiredResourceVersionMutation)
|
|
r.POST("/resource-versions/forks/:id/rebase", retiredResourceVersionMutation)
|
|
r.POST("/resource-versions/submissions/:id/withdraw", retiredResourceVersionMutation)
|
|
r.POST("/resource-versions/submissions/:id/review", retiredResourceVersionMutation)
|
|
r.GET("/resource-versions/submissions/:id/knowledge", s.GetKnowledgeVersionReview)
|
|
r.POST("/resource-versions/jobs/:id/retry", retiredResourceVersionMutation)
|
|
}
|
|
|
|
func retiredResourceVersionMutation(c *gin.Context) {
|
|
fail(c, 410, "资源版本功能已停用;授权和全局资源可直接查看、使用,不支持创建副本或修改原资源")
|
|
}
|
|
|
|
func (s *Service) GetResourceVersion(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
t, u := identity(ctx)
|
|
var f ResourceFork
|
|
q := s.db.WithContext(ctx).Where("tenant_id=? AND id=?", t, c.Param("id"))
|
|
if !administrator(ctx) {
|
|
q = q.Where("owner_id=?", u)
|
|
}
|
|
if err := q.First(&f).Error; err != nil {
|
|
dbResult(c, err, nil)
|
|
return
|
|
}
|
|
if err := versionPermission(ctx, f.Kind, false); err != nil {
|
|
versionResult(c, err, nil)
|
|
return
|
|
}
|
|
body, hash, err := s.resourceSnapshot(s.db.WithContext(ctx), ctx, f.Kind, f.PersonalID)
|
|
versionResult(c, err, gin.H{"fork": f, "snapshot": body, "fingerprint": hash})
|
|
}
|
|
|
|
func (s *Service) EditResourceVersion(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
t, u := identity(ctx)
|
|
var req struct {
|
|
Fingerprint string `json:"fingerprint"`
|
|
Snapshot types.JSON `json:"snapshot"`
|
|
}
|
|
if c.ShouldBindJSON(&req) != nil || len(req.Snapshot) > 256*1024 || req.Fingerprint == "" {
|
|
fail(c, 400, "请刷新个人版本后再编辑")
|
|
return
|
|
}
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var f ResourceFork
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND owner_id=? AND id=?", t, u, c.Param("id")).First(&f).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := versionPermission(ctx, f.Kind, true); err != nil {
|
|
return err
|
|
}
|
|
r, err := sourceView(tx, t, f.Kind, f.PersonalID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if r.OwnerID != u {
|
|
return errVersionDenied
|
|
}
|
|
_, hash, err := s.resourceSnapshot(tx, ctx, f.Kind, f.PersonalID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hash != req.Fingerprint {
|
|
return errConflict
|
|
}
|
|
if err = s.validateVersionReferences(tx, ctx, f.Kind, req.Snapshot, false); err != nil {
|
|
return err
|
|
}
|
|
if err = s.validateVersionBody(f.Kind, req.Snapshot); err != nil {
|
|
return err
|
|
}
|
|
if err = s.applyVersionResource(tx, ctx, f.Kind, f.PersonalID, req.Snapshot); err != nil {
|
|
return err
|
|
}
|
|
return s.audit(tx, ctx, "resource.personal_edited", f.ID, map[string]any{"kind": f.Kind})
|
|
})
|
|
versionResult(c, err, true)
|
|
}
|
|
|
|
func (s *Service) RebaseResourceVersion(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
t, u := identity(ctx)
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var f ResourceFork
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND owner_id=? AND id=?", t, u, c.Param("id")).First(&f).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := versionPermission(ctx, f.Kind, true); err != nil {
|
|
return err
|
|
}
|
|
var pending int64
|
|
if err := tx.Model(&ResourceSubmission{}).Where("tenant_id=? AND fork_id=? AND status IN ?", t, f.ID, []string{"pending", "snapshotting"}).Count(&pending).Error; err != nil {
|
|
return err
|
|
}
|
|
if pending > 0 {
|
|
return fmt.Errorf("%w: 请先撤回待审核版本", errConflict)
|
|
}
|
|
v, err := sourceView(tx, t, f.Kind, f.SourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !v.Global {
|
|
return errVersionDenied
|
|
}
|
|
body, hash, err := s.resourceSnapshot(tx, ctx, f.Kind, f.SourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f.BaseSnapshot = body
|
|
f.BaseFingerprint = hash
|
|
if err = tx.Save(&f).Error; err != nil {
|
|
return err
|
|
}
|
|
return s.audit(tx, ctx, "resource.personal_rebased", f.ID, map[string]any{"kind": f.Kind})
|
|
})
|
|
versionResult(c, err, true)
|
|
}
|
|
|
|
func (s *Service) WithdrawResourceVersion(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
t, u := identity(ctx)
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var r ResourceSubmission
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND owner_id=? AND id=?", t, u, c.Param("id")).First(&r).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := versionPermission(ctx, r.Kind, true); err != nil {
|
|
return err
|
|
}
|
|
if r.Status != "pending" {
|
|
return errConflict
|
|
}
|
|
r.Status = "withdrawn"
|
|
if err := tx.Save(&r).Error; err != nil {
|
|
return err
|
|
}
|
|
return s.audit(tx, ctx, "resource.version_withdrawn", r.ID, map[string]any{"kind": r.Kind})
|
|
})
|
|
versionResult(c, err, true)
|
|
}
|
|
|
|
func fingerprint(value any) (types.JSON, string, error) {
|
|
b, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
h := sha256.Sum256(b)
|
|
return types.JSON(b), hex.EncodeToString(h[:]), nil
|
|
}
|
|
|
|
func versionResult(c *gin.Context, err error, value any) {
|
|
if errors.Is(err, errVersionDenied) {
|
|
fail(c, 403, err.Error())
|
|
return
|
|
}
|
|
dbResult(c, err, value)
|
|
}
|
|
|
|
func sourceView(tx *gorm.DB, tenant uint64, kind, id string) (ResourceView, error) {
|
|
spec, ok := resourceTables[kind]
|
|
if !ok {
|
|
return ResourceView{}, errInvalid
|
|
}
|
|
visibility := "false AS global"
|
|
if kind == "knowledge" {
|
|
visibility = "true AS global"
|
|
}
|
|
if kind == "agents" || kind == "skills" {
|
|
visibility = "(visibility='global' AND enabled=true) AS global"
|
|
}
|
|
r := ResourceView{Kind: kind}
|
|
if err := tx.Table(spec.table).Clauses(clause.Locking{Strength: "UPDATE"}).Select("id,name,"+spec.owner+" AS owner_id,"+visibility).Where("tenant_id=? AND id=?", tenant, id).Where(spec.where).Take(&r).Error; err != nil {
|
|
return r, err
|
|
}
|
|
var p Publication
|
|
q := tx.Where("tenant_id=? AND kind=? AND resource_id=?", tenant, kind, id).Limit(1).Find(&p)
|
|
if q.Error != nil {
|
|
return r, q.Error
|
|
}
|
|
if q.RowsAffected > 0 {
|
|
r.Global = p.Global
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func (s *Service) VersionCatalog(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
t, u := identity(ctx)
|
|
if t == 0 || u == "" || types.IsSyntheticUserID(u) {
|
|
fail(c, 403, "请使用个人账号")
|
|
return
|
|
}
|
|
rows, err := resources(s.db.WithContext(ctx), t)
|
|
if err != nil {
|
|
dbResult(c, err, nil)
|
|
return
|
|
}
|
|
hidden := []ResourceHidden{}
|
|
if err = s.db.Where("tenant_id=? AND owner_id=?", t, u).Find(&hidden).Error; err != nil {
|
|
dbResult(c, err, nil)
|
|
return
|
|
}
|
|
h := map[string]bool{}
|
|
for _, v := range hidden {
|
|
h[v.Kind+":"+v.ResourceID] = true
|
|
}
|
|
out := []map[string]any{}
|
|
for _, v := range rows {
|
|
if versionPermission(ctx, v.Kind, false) != nil || !v.Global {
|
|
continue
|
|
}
|
|
out = append(out, map[string]any{"kind": v.Kind, "id": v.ID, "name": v.Name, "owner_name": v.OwnerName, "hidden": h[v.Kind+":"+v.ID], "can_fork": false})
|
|
}
|
|
data(c, out)
|
|
}
|
|
|
|
func (s *Service) ListResourceVersions(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
t, u := identity(ctx)
|
|
if t == 0 || u == "" || types.IsSyntheticUserID(u) {
|
|
fail(c, 403, "请使用个人账号")
|
|
return
|
|
}
|
|
q := s.db.WithContext(ctx).Where("tenant_id=?", t)
|
|
if !administrator(ctx) {
|
|
q = q.Where("owner_id=?", u)
|
|
}
|
|
forks := []ResourceFork{}
|
|
submissions := []ResourceSubmission{}
|
|
history := []ResourceVersionHistory{}
|
|
if err := q.Session(&gorm.Session{}).Order("created_at DESC").Find(&forks).Error; err != nil {
|
|
dbResult(c, err, nil)
|
|
return
|
|
}
|
|
if err := q.Session(&gorm.Session{}).Order("created_at DESC").Find(&submissions).Error; err != nil {
|
|
dbResult(c, err, nil)
|
|
return
|
|
}
|
|
if err := q.Session(&gorm.Session{}).Order("created_at DESC").Find(&history).Error; err != nil {
|
|
dbResult(c, err, nil)
|
|
return
|
|
}
|
|
ff := []ResourceFork{}
|
|
ss := []ResourceSubmission{}
|
|
var hidden []ResourceHidden
|
|
if err := s.db.WithContext(ctx).Where("tenant_id=? AND owner_id=?", t, u).Find(&hidden).Error; err != nil {
|
|
dbResult(c, err, nil)
|
|
return
|
|
}
|
|
hiddenIDs := map[string]bool{}
|
|
for _, v := range hidden {
|
|
hiddenIDs[v.Kind+":"+v.ResourceID] = true
|
|
}
|
|
for _, v := range forks {
|
|
if versionPermission(ctx, v.Kind, false) == nil {
|
|
v.Hidden = hiddenIDs[v.Kind+":"+v.PersonalID]
|
|
ff = append(ff, v)
|
|
}
|
|
}
|
|
for _, v := range submissions {
|
|
if versionPermission(ctx, v.Kind, false) == nil {
|
|
ss = append(ss, v)
|
|
}
|
|
}
|
|
var names []struct{ ID, Username string }
|
|
if err := s.db.Table("users").Select("id,username").Where("id IN (SELECT user_id FROM tenant_members WHERE tenant_id=? AND deleted_at IS NULL)", t).Scan(&names).Error; err != nil {
|
|
dbResult(c, err, nil)
|
|
return
|
|
}
|
|
n := map[string]string{}
|
|
for _, v := range names {
|
|
n[v.ID] = v.Username
|
|
}
|
|
for i := range ss {
|
|
ss[i].OwnerName = n[ss[i].OwnerID]
|
|
}
|
|
hh := []ResourceVersionHistory{}
|
|
for _, v := range history {
|
|
if versionPermission(ctx, v.Kind, false) == nil {
|
|
v.OwnerName = n[v.OwnerID]
|
|
hh = append(hh, v)
|
|
}
|
|
}
|
|
jobs := []KnowledgeVersionJob{}
|
|
if versionPermission(ctx, "knowledge", false) == nil && s.versionKnowledge != nil {
|
|
if err := q.Session(&gorm.Session{}).Order("created_at DESC").Limit(100).Find(&jobs).Error; err != nil {
|
|
dbResult(c, err, nil)
|
|
return
|
|
}
|
|
}
|
|
data(c, gin.H{"forks": ff, "submissions": ss, "history": hh, "jobs": jobs})
|
|
}
|
|
|
|
func (s *Service) ForkResource(c *gin.Context) {
|
|
result, err := s.forkResource(c.Request.Context(), c.Param("kind"), c.Param("id"))
|
|
versionResult(c, err, result)
|
|
}
|
|
|
|
func (s *Service) forkResource(ctx context.Context, kind, id string) (*ResourceFork, error) {
|
|
if err := versionPermission(ctx, kind, true); err != nil {
|
|
return nil, err
|
|
}
|
|
t, u := identity(ctx)
|
|
out := &ResourceFork{}
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if kind == "knowledge" && s.versionKnowledge != nil {
|
|
current, err := resolveKnowledgeVersion(tx, t, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id = current
|
|
}
|
|
source, err := sourceView(tx, t, kind, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !source.Global {
|
|
return fmt.Errorf("%w: 只能创建全局资源的个人版本", errVersionDenied)
|
|
}
|
|
q := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND owner_id=? AND kind=? AND source_id=?", t, u, kind, id).Limit(1).Find(out)
|
|
if q.Error != nil {
|
|
return q.Error
|
|
}
|
|
previousForkID := ""
|
|
previousCreatedAt := out.CreatedAt
|
|
if q.RowsAffected > 0 {
|
|
// Knowledge copies can still be queued before their target exists.
|
|
if kind == "knowledge" {
|
|
return nil
|
|
}
|
|
if _, err := sourceView(tx, t, kind, out.PersonalID); err == nil {
|
|
return nil
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
var pending int64
|
|
if err := tx.Model(&ResourceSubmission{}).Where("tenant_id=? AND fork_id=? AND status='pending'", t, out.ID).Count(&pending).Error; err != nil {
|
|
return err
|
|
}
|
|
if pending > 0 {
|
|
return fmt.Errorf("%w: 旧个人版本已删除,请先撤回待审核版本后重新创建", errConflict)
|
|
}
|
|
previousForkID = out.ID
|
|
}
|
|
if kind == "knowledge" {
|
|
return s.reserveKnowledgeFork(tx, ctx, source, out)
|
|
}
|
|
body, hash, err := s.resourceSnapshot(tx, ctx, kind, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = s.validateVersionReferences(tx, ctx, kind, body, false); err != nil {
|
|
return err
|
|
}
|
|
personal := uuid.NewString()
|
|
if err = s.cloneVersionResource(tx, ctx, kind, id, personal); err != nil {
|
|
return err
|
|
}
|
|
*out = ResourceFork{ID: uuid.NewString(), TenantID: t, OwnerID: u, Kind: kind, SourceID: id, PersonalID: personal, Name: source.Name, BaseFingerprint: hash, BaseSnapshot: body, NoExport: true}
|
|
if previousForkID != "" {
|
|
out.ID = previousForkID
|
|
out.CreatedAt = previousCreatedAt
|
|
err = tx.Save(out).Error
|
|
} else {
|
|
err = tx.Create(out).Error
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = tx.Create(&Publication{TenantID: t, Kind: kind, ResourceID: personal, Global: false}).Error; err != nil {
|
|
return err
|
|
}
|
|
return s.audit(tx, ctx, "resource.personal_created", out.ID, map[string]any{"kind": kind, "source_id": id, "personal_id": personal})
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
func (s *Service) HideResource(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
kind, id := c.Param("kind"), c.Param("id")
|
|
if err := versionPermission(ctx, kind, true); err != nil {
|
|
versionResult(c, err, nil)
|
|
return
|
|
}
|
|
var req struct {
|
|
Hidden *bool `json:"hidden"`
|
|
}
|
|
if c.ShouldBindJSON(&req) != nil || req.Hidden == nil {
|
|
fail(c, 400, "请选择隐藏或恢复")
|
|
return
|
|
}
|
|
t, u := identity(ctx)
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
r, err := sourceView(tx, t, kind, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var forks int64
|
|
if err = tx.Model(&ResourceFork{}).Where("tenant_id=? AND owner_id=? AND kind=? AND personal_id=?", t, u, kind, id).Count(&forks).Error; err != nil {
|
|
return err
|
|
}
|
|
if !r.Global && forks == 0 {
|
|
return errVersionDenied
|
|
}
|
|
row := ResourceHidden{TenantID: t, OwnerID: u, Kind: kind, ResourceID: id}
|
|
if *req.Hidden {
|
|
err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
|
|
} else {
|
|
err = tx.Delete(&row).Error
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.audit(tx, ctx, "resource.personal_visibility", id, map[string]any{"kind": kind, "hidden": *req.Hidden})
|
|
})
|
|
versionResult(c, err, true)
|
|
}
|
|
|
|
func (s *Service) SubmitResourceVersion(c *gin.Context) {
|
|
var req struct {
|
|
Note string `json:"note"`
|
|
}
|
|
if c.ShouldBindJSON(&req) != nil || len(req.Note) > 4000 {
|
|
fail(c, 400, "请填写不超过 4000 字节的修改说明")
|
|
return
|
|
}
|
|
row, err := s.submitResourceVersion(c.Request.Context(), c.Param("id"), req.Note)
|
|
versionResult(c, err, row)
|
|
}
|
|
|
|
func (s *Service) submitResourceVersion(ctx context.Context, id, note string) (*ResourceSubmission, error) {
|
|
t, u := identity(ctx)
|
|
out := &ResourceSubmission{}
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var f ResourceFork
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND owner_id=? AND id=?", t, u, id).First(&f).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := versionPermission(ctx, f.Kind, true); err != nil {
|
|
return err
|
|
}
|
|
if f.Kind == "knowledge" {
|
|
return s.reserveKnowledgeSubmission(tx, ctx, f, note, out)
|
|
}
|
|
r, err := sourceView(tx, t, f.Kind, f.SourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !r.Global {
|
|
return errVersionDenied
|
|
}
|
|
personal, err := sourceView(tx, t, f.Kind, f.PersonalID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if personal.OwnerID != u {
|
|
return errVersionDenied
|
|
}
|
|
var pending int64
|
|
if err = tx.Model(&ResourceSubmission{}).Where("tenant_id=? AND fork_id=? AND status='pending'", t, f.ID).Count(&pending).Error; err != nil {
|
|
return err
|
|
}
|
|
if pending > 0 {
|
|
return fmt.Errorf("%w: 已有待审核版本", errConflict)
|
|
}
|
|
body, hash, err := s.resourceSnapshot(tx, ctx, f.Kind, f.PersonalID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = s.validateVersionReferences(tx, ctx, f.Kind, body, true); err != nil {
|
|
return err
|
|
}
|
|
if err = s.validateVersionBody(f.Kind, body); err != nil {
|
|
return err
|
|
}
|
|
*out = ResourceSubmission{ID: uuid.NewString(), TenantID: t, OwnerID: u, ForkID: f.ID, Kind: f.Kind, SourceID: f.SourceID, PersonalID: f.PersonalID, Name: personal.Name, Status: "pending", BaseFingerprint: f.BaseFingerprint, PersonalFingerprint: hash, BaseSnapshot: f.BaseSnapshot, ProposedSnapshot: body, Note: strings.TrimSpace(note)}
|
|
if err = tx.Create(out).Error; err != nil {
|
|
return err
|
|
}
|
|
return s.audit(tx, ctx, "resource.version_submitted", out.ID, map[string]any{"kind": f.Kind, "source_id": f.SourceID})
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
func (s *Service) ReviewResourceVersion(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
if !administrator(ctx) {
|
|
fail(c, 403, "需要当前空间管理员权限")
|
|
return
|
|
}
|
|
var req struct {
|
|
Approve *bool `json:"approve"`
|
|
Note string `json:"note"`
|
|
}
|
|
if c.ShouldBindJSON(&req) != nil || req.Approve == nil || len(req.Note) > 4000 {
|
|
fail(c, 400, "请选择审核结果并限制说明长度")
|
|
return
|
|
}
|
|
err := s.reviewResourceVersion(ctx, c.Param("id"), *req.Approve, req.Note)
|
|
versionResult(c, err, true)
|
|
}
|
|
|
|
func (s *Service) reviewResourceVersion(ctx context.Context, id string, approve bool, note string) error {
|
|
if !administrator(ctx) {
|
|
return errVersionDenied
|
|
}
|
|
t, u := identity(ctx)
|
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var r ResourceSubmission
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND id=?", t, id).First(&r).Error; err != nil {
|
|
return err
|
|
}
|
|
if r.Status != "pending" {
|
|
return errConflict
|
|
}
|
|
if r.Kind == "knowledge" {
|
|
return s.reviewKnowledgeSubmission(tx, ctx, &r, approve, note)
|
|
}
|
|
if approve {
|
|
source, err := sourceView(tx, t, r.Kind, r.SourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !source.Global {
|
|
return fmt.Errorf("%w: 原资源已取消全局发布", errConflict)
|
|
}
|
|
_, hash, err := s.resourceSnapshot(tx, ctx, r.Kind, r.SourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hash != r.BaseFingerprint {
|
|
return fmt.Errorf("%w: 全局资源已有新版本,请重新创建个人版本后提交", errConflict)
|
|
}
|
|
if err = s.validateVersionReferences(tx, ctx, r.Kind, r.ProposedSnapshot, true); err != nil {
|
|
return err
|
|
}
|
|
if err = s.validateVersionBody(r.Kind, r.ProposedSnapshot); err != nil {
|
|
return err
|
|
}
|
|
if err = s.applyVersionResource(tx, ctx, r.Kind, r.SourceID, r.ProposedSnapshot); err != nil {
|
|
return err
|
|
}
|
|
r.Status = "approved"
|
|
body, newHash, err := s.resourceSnapshot(tx, ctx, r.Kind, r.SourceID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = tx.Model(&ResourceFork{}).Where("tenant_id=? AND id=?", t, r.ForkID).Updates(map[string]any{"base_fingerprint": newHash, "base_snapshot": body}).Error; err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
r.Status = "rejected"
|
|
}
|
|
now := time.Now()
|
|
r.ReviewedAt = &now
|
|
r.ReviewerID = u
|
|
r.ReviewNote = strings.TrimSpace(note)
|
|
if err := tx.Save(&r).Error; err != nil {
|
|
return err
|
|
}
|
|
return s.audit(tx, ctx, "resource.version_"+r.Status, r.ID, map[string]any{"kind": r.Kind, "source_id": r.SourceID, "submitter": r.OwnerID})
|
|
})
|
|
}
|