WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+692
View File
@@ -0,0 +1,692 @@
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})
})
}