feat(backend): 設備註冊 + 模型共享 backend(B 設備管理 + C 模型共享)
B 設備管理(feature-device-mgmt-tdd): - POST /api/devices/:id/register + /unregister(owner 檢查 + representative 擋 + 已註冊擋 + SetRegistered 單欄翻轉,不碰 unpair 軟刪) - error codes ALREADY_REGISTERED / REPRESENTATIVE_DEVICE(409) - 不需 migration(registered_at 欄/index/讀寫已在 0005) C 模型共享(feature-model-sharing-tdd,security 深審 APPROVE): - migration 0006:models.visibility enum DEFAULT 'private'(零行為改變)+ model_shares 表 - canAccessModel single source(owner ∪ share ∪ public ∪ tenant):profile + download 共用 - GET /library(cursor keyset)/ GET /:id/profile(404 防列舉、GetWithOwner join name 不洩 email) / PATCH /:id/visibility(owner-only)/ shares CRUD / download 放寬 - tenant 因 OIDC 無 org claim 留 stub(恆空、安全預設;補 org claim 需重送 security 深審) reviewer 通過(B 三條紅線 / C security APPROVE 無 C/M)。130 dbtest 全綠、gosec 新檔 0。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
17134e8eae
commit
47a1d4d0ef
216
visionA-backend/internal/api/device_register.go
Normal file
216
visionA-backend/internal/api/device_register.go
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
// device_register.go — POST /api/devices/:id/register 與 /unregister 的 handler。
|
||||||
|
//
|
||||||
|
// 「註冊」語意軸(feature-device-mgmt-tdd §3 / §4,api/api-device-mgmt.md):
|
||||||
|
// - register:把 device 的 registered_at 由 NULL 翻成 now()(未註冊 → 已註冊)。
|
||||||
|
// - unregister:把 registered_at 清成 NULL(退回未註冊),**保留裝置列**。
|
||||||
|
//
|
||||||
|
// 🔴 與 unpair 完全不同(TDD §1 紅線):unpair 軟刪整台 + cascade 撤 token(device 從清單
|
||||||
|
// 消失);unregister 只清單欄 registered_at(device 仍在清單、顯示為未註冊)。兩端點各走各的,
|
||||||
|
// 本檔**絕不呼叫** DeviceUnpairer / Delete / 撤 token,也**不改** devicesUnpairHandler。
|
||||||
|
//
|
||||||
|
// 皆為純雲端 DB 操作(只翻 registered_at、不路由 local agent),用 UUID `:id` 識別
|
||||||
|
// (對齊 ADR-018 FE-A:DB 操作用 UUID、路由操作才用 serial)。
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"visiona-backend/internal/device"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deviceRegisterCommon 執行 register / unregister 共用的前置檢查(步驟 1-5,兩端點一致):
|
||||||
|
//
|
||||||
|
// 1. 缺 UserContext → 500(auth middleware 沒配好,不可 fallthrough)
|
||||||
|
// 2. :id 空 → 400 VALIDATION_FAILED
|
||||||
|
// 3. Get device:ErrNotFound → 404;其他 DB error → WriteDBError
|
||||||
|
// 4. owner 檢查(IDOR 主防線):d.OwnerUserID != userID → 403 FORBIDDEN
|
||||||
|
// 5. representative 檢查:d.IsRepresentative → 409 REPRESENTATIVE_DEVICE
|
||||||
|
//
|
||||||
|
// 回傳 (device, userID, ok);ok=false 時已寫好回應,caller 直接 return。
|
||||||
|
//
|
||||||
|
// owner 檢查對 register/unregister 都必做——不能因「只是翻 flag」省略(TDD §7.2 IDOR)。
|
||||||
|
func deviceRegisterCommon(c *gin.Context, deps Deps, ctx context.Context) (*device.Device, string, bool) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "device id required", nil)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 0.7 security fix C1:強制要求 UserContext 非空(見既有 devices.go 範式)。
|
||||||
|
uc, ok := UserContextFrom(c)
|
||||||
|
if !ok || uc.UserID == "" {
|
||||||
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
|
"missing user context (auth middleware misconfigured?)", nil)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
userID := uc.UserID
|
||||||
|
|
||||||
|
d, err := deps.DeviceRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, device.ErrNotFound) {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
// DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||||||
|
WriteDBError(c, deps.Logger, "get device", err)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// owner 檢查(IDOR 主防線,TDD §7.1/§7.2):沿用既有 handler 慣例(devices.go:197-201)。
|
||||||
|
if d.OwnerUserID != userID {
|
||||||
|
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner of this device", nil)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// representative 檢查(TDD §7.3):representative 是 agent 連線佔位、非真 USB,
|
||||||
|
// 註冊語意不適用。縱深——即使 List 已濾掉 representative(前端拿不到其 UUID),
|
||||||
|
// handler 仍自己擋;repo SetRegistered 的 WHERE 帶 is_representative=false 為第三層。
|
||||||
|
if d.IsRepresentative {
|
||||||
|
WriteError(c, http.StatusConflict, ErrCodeRepresentativeDevice,
|
||||||
|
"representative device cannot be registered", nil)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return d, userID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// devicesRegisterHandler 實作 POST /api/devices/:id/register。
|
||||||
|
//
|
||||||
|
// 行為順序(api-device-mgmt.md §1):共用前置(1-5)→ 已註冊檢查(6,409 ALREADY_REGISTERED)
|
||||||
|
// → SetRegistered(now())(7)→ 200 + 更新後 DeviceListItem(registered_at 非 null)。
|
||||||
|
func devicesRegisterHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if deps.DeviceRepo == nil {
|
||||||
|
WriteNotImplemented(c, "device repo not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
d, userID, ok := deviceRegisterCommon(c, deps, ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已註冊檢查(TDD §3.2):registered_at 非 nil → 409 ALREADY_REGISTERED。
|
||||||
|
// 前端據此顯示「此裝置已註冊」並 refetch。
|
||||||
|
if d.RegisteredAt != nil {
|
||||||
|
WriteError(c, http.StatusConflict, ErrCodeAlreadyRegistered,
|
||||||
|
"device already registered", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := deps.DeviceRepo.SetRegistered(ctx, d.ID, &now); err != nil {
|
||||||
|
if errors.Is(err, device.ErrNotFound) {
|
||||||
|
// 競態:Get 之後、SetRegistered 之前 device 被軟刪 / 轉 representative。
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteDBError(c, deps.Logger, "register device", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logOrDefault(deps.Logger).Info("devices: registered",
|
||||||
|
"device_id", d.ID,
|
||||||
|
"user_id", userID,
|
||||||
|
"request_id", RequestIDFrom(c))
|
||||||
|
|
||||||
|
writeDeviceItemAfterRegister(c, deps, ctx, d.ID, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// devicesUnregisterHandler 實作 POST /api/devices/:id/unregister(退回未註冊)。
|
||||||
|
//
|
||||||
|
// 行為順序(api-device-mgmt.md §2):共用前置(1-5)→ SetRegistered(nil)(冪等,未註冊也回 200)
|
||||||
|
// → 200 + 更新後 DeviceListItem(registered_at=null)。
|
||||||
|
//
|
||||||
|
// 🔴 絕不軟刪、不呼叫 DeviceUnpairer、不撤 token、不動 session(TDD §1.2)。與 unpair 各走各的。
|
||||||
|
func devicesUnregisterHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if deps.DeviceRepo == nil {
|
||||||
|
WriteNotImplemented(c, "device repo not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
d, userID, ok := deviceRegisterCommon(c, deps, ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 冪等(TDD §4.1 步驟 2):不做「已註冊才可取消」的硬擋。SetRegistered(nil) 對已 NULL
|
||||||
|
// 的列 UPDATE 到相同值、RowsAffected 仍為 1(WHERE 命中),避免使用者連點兩次第二次報錯。
|
||||||
|
if err := deps.DeviceRepo.SetRegistered(ctx, d.ID, nil); err != nil {
|
||||||
|
if errors.Is(err, device.ErrNotFound) {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteDBError(c, deps.Logger, "unregister device", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logOrDefault(deps.Logger).Info("devices: unregistered",
|
||||||
|
"device_id", d.ID,
|
||||||
|
"user_id", userID,
|
||||||
|
"request_id", RequestIDFrom(c))
|
||||||
|
|
||||||
|
writeDeviceItemAfterRegister(c, deps, ctx, d.ID, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeDeviceItemAfterRegister 重新 Get device 並回 200 + 更新後 DeviceListItem。
|
||||||
|
//
|
||||||
|
// 為什麼重新 Get 而非就地拼裝:SetRegistered 只回 error,最新的 registered_at / updated_at
|
||||||
|
// 以 DB 為準最不易出錯(避免手動拼裝與 DB 值漂移)。合併 tunnel 狀態沿用既有 list/get 範式。
|
||||||
|
//
|
||||||
|
// register/unregister 後 device 必然存在(剛剛才 UPDATE 成功),Get 理論上不會 NotFound;
|
||||||
|
// 若極端競態下被刪,回 404(不 panic)。
|
||||||
|
func writeDeviceItemAfterRegister(c *gin.Context, deps Deps, ctx context.Context, id, userID string) {
|
||||||
|
d, err := deps.DeviceRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, device.ErrNotFound) {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteDBError(c, deps.Logger, "get device after register", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// tunnel 狀態合併:獨立 ctx 給 3s 預算(對齊 list/get,避免被前面 DB 呼叫吃掉 → R-3 誤判)。
|
||||||
|
tunnelCtx, tunnelCancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer tunnelCancel()
|
||||||
|
tunnelAlive, lastSeen := resolveTunnelStatus(
|
||||||
|
tunnelCtx, deps.SessionStore, userID, deps.Logger, "register", RequestIDFrom(c))
|
||||||
|
|
||||||
|
item := DeviceListItem{
|
||||||
|
ID: d.ID,
|
||||||
|
Name: d.Name,
|
||||||
|
DeviceType: d.DeviceType,
|
||||||
|
SerialNumber: d.SerialNumber,
|
||||||
|
AgentID: d.AgentID,
|
||||||
|
RegisteredAt: d.RegisteredAt,
|
||||||
|
RemoteStatus: d.RemoteStatus,
|
||||||
|
LastSeenAt: d.LastSeenAt,
|
||||||
|
LastConnectedAt: d.LastConnectedAt,
|
||||||
|
USBStatus: d.Status,
|
||||||
|
TunnelOnline: tunnelAlive,
|
||||||
|
CreatedAt: d.CreatedAt,
|
||||||
|
UpdatedAt: d.UpdatedAt,
|
||||||
|
}
|
||||||
|
if item.LastSeenAt == nil && tunnelAlive && !lastSeen.IsZero() {
|
||||||
|
ls := lastSeen
|
||||||
|
item.LastSeenAt = &ls
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(c, http.StatusOK, item)
|
||||||
|
}
|
||||||
250
visionA-backend/internal/api/device_register_test.go
Normal file
250
visionA-backend/internal/api/device_register_test.go
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"visiona-backend/internal/device"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newRegisterFixture 建 router(InMemory repo + 無 session),user context = demo-user。
|
||||||
|
// 回傳 router + repo 供測試直接塞 device / 驗 registered_at。
|
||||||
|
func newRegisterFixture(t *testing.T) (*gin.Engine, *device.InMemoryRepository) {
|
||||||
|
t.Helper()
|
||||||
|
repo := device.NewInMemoryRepository()
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(RequestIDMiddleware())
|
||||||
|
r.Use(injectStaticUserContext("demo-user", ""))
|
||||||
|
g := r.Group("/api")
|
||||||
|
registerDeviceRoutes(g, Deps{
|
||||||
|
DeviceRepo: repo,
|
||||||
|
SessionStore: &fakeSessionStore{},
|
||||||
|
})
|
||||||
|
return r, repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func postRegister(t *testing.T, r *gin.Engine, path string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, path, nil))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// errCodeOf 解析錯誤回應的 error.code。
|
||||||
|
func errCodeOf(t *testing.T, w *httptest.ResponseRecorder) string {
|
||||||
|
t.Helper()
|
||||||
|
var eb ErrorBody
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &eb), "body=%s", w.Body.String())
|
||||||
|
require.NotNil(t, eb.Error)
|
||||||
|
return eb.Error.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// dataItemOf 解析成功回應的 data(DeviceListItem map)。
|
||||||
|
func dataItemOf(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var sb SuccessBody
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb), "body=%s", w.Body.String())
|
||||||
|
item, ok := sb.Data.(map[string]any)
|
||||||
|
require.True(t, ok, "data should be object, body=%s", w.Body.String())
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// register
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestRegister_Success 未註冊 → register → 200 且 registered_at 非 null。
|
||||||
|
func TestRegister_Success(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "demo-user", Name: "usb", DeviceType: "kl520",
|
||||||
|
SerialNumber: "0xAAAA", // 未註冊:RegisteredAt 留 nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/register")
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||||
|
|
||||||
|
item := dataItemOf(t, w)
|
||||||
|
assert.Equal(t, "d1", item["id"])
|
||||||
|
assert.NotNil(t, item["registered_at"], "register 後 registered_at 應非 null")
|
||||||
|
assert.NotEmpty(t, item["registered_at"])
|
||||||
|
|
||||||
|
// repo 端也確認翻轉。
|
||||||
|
got, err := repo.Get(context.Background(), "d1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got.RegisteredAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegister_AlreadyRegistered 已註冊再 register → 409 ALREADY_REGISTERED。
|
||||||
|
func TestRegister_AlreadyRegistered(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
past := time.Now().UTC().Add(-time.Hour)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA",
|
||||||
|
RegisteredAt: &past,
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/register")
|
||||||
|
require.Equal(t, http.StatusConflict, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeAlreadyRegistered, errCodeOf(t, w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegister_NotOwner 非 owner → 403 FORBIDDEN(IDOR 主防線)。
|
||||||
|
func TestRegister_NotOwner(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "someone-else", Name: "usb", SerialNumber: "0xAAAA",
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/register")
|
||||||
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeForbidden, errCodeOf(t, w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegister_NotFound device 不存在 → 404。
|
||||||
|
func TestRegister_NotFound(t *testing.T) {
|
||||||
|
r, _ := newRegisterFixture(t)
|
||||||
|
w := postRegister(t, r, "/api/devices/ghost/register")
|
||||||
|
require.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeNotFound, errCodeOf(t, w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegister_Representative representative device → 409 REPRESENTATIVE_DEVICE。
|
||||||
|
func TestRegister_Representative(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "rep", OwnerUserID: "demo-user", Name: "agent", IsRepresentative: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/rep/register")
|
||||||
|
require.Equal(t, http.StatusConflict, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeRepresentativeDevice, errCodeOf(t, w),
|
||||||
|
"representative 用 REPRESENTATIVE_DEVICE 碼區分於 ALREADY_REGISTERED")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegister_MissingUserContext 缺 UserContext → 500(auth 沒配好不可 fallthrough)。
|
||||||
|
func TestRegister_MissingUserContext(t *testing.T) {
|
||||||
|
repo := device.NewInMemoryRepository()
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA",
|
||||||
|
}))
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(RequestIDMiddleware())
|
||||||
|
// 刻意不注入 UserContext。
|
||||||
|
g := r.Group("/api")
|
||||||
|
registerDeviceRoutes(g, Deps{DeviceRepo: repo, SessionStore: &fakeSessionStore{}})
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/register")
|
||||||
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeInternalError, errCodeOf(t, w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// unregister
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestUnregister_Success 已註冊 → unregister → 200 且 registered_at=null,device 仍在 List。
|
||||||
|
func TestUnregister_Success(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
past := time.Now().UTC().Add(-time.Hour)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA",
|
||||||
|
RegisteredAt: &past,
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/unregister")
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||||
|
|
||||||
|
item := dataItemOf(t, w)
|
||||||
|
assert.Nil(t, item["registered_at"], "unregister 後 registered_at 應為 null")
|
||||||
|
|
||||||
|
// device 仍存在(未軟刪、保留列)。
|
||||||
|
got, err := repo.Get(context.Background(), "d1")
|
||||||
|
require.NoError(t, err, "unregister 不軟刪、device 應仍在")
|
||||||
|
assert.Nil(t, got.RegisteredAt)
|
||||||
|
|
||||||
|
// 仍列在 List。
|
||||||
|
list, err := repo.List(context.Background(), "demo-user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, list, 1, "unregister 後 device 仍在清單(與 unpair 不同)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnregister_IdempotentWhenUnregistered 未註冊 → unregister → 200 冪等 no-op。
|
||||||
|
func TestUnregister_IdempotentWhenUnregistered(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA",
|
||||||
|
// RegisteredAt nil = 未註冊
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/unregister")
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "未註冊 unregister 應冪等回 200,body=%s", w.Body.String())
|
||||||
|
item := dataItemOf(t, w)
|
||||||
|
assert.Nil(t, item["registered_at"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnregister_NotOwner 非 owner → 403。
|
||||||
|
func TestUnregister_NotOwner(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
past := time.Now().UTC()
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "someone-else", Name: "usb", SerialNumber: "0xAAAA",
|
||||||
|
RegisteredAt: &past,
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/unregister")
|
||||||
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeForbidden, errCodeOf(t, w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnregister_Representative representative → 409 REPRESENTATIVE_DEVICE。
|
||||||
|
func TestUnregister_Representative(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "rep", OwnerUserID: "demo-user", Name: "agent", IsRepresentative: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/rep/unregister")
|
||||||
|
require.Equal(t, http.StatusConflict, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeRepresentativeDevice, errCodeOf(t, w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnregister_NotFound device 不存在 → 404。
|
||||||
|
func TestUnregister_NotFound(t *testing.T) {
|
||||||
|
r, _ := newRegisterFixture(t)
|
||||||
|
w := postRegister(t, r, "/api/devices/ghost/unregister")
|
||||||
|
require.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeNotFound, errCodeOf(t, w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterUnregister_RoundTrip register → 綠,unregister → 退回,device 全程保留。
|
||||||
|
func TestRegisterUnregister_RoundTrip(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA",
|
||||||
|
}))
|
||||||
|
|
||||||
|
// register
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/register")
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
got, _ := repo.Get(context.Background(), "d1")
|
||||||
|
require.NotNil(t, got.RegisteredAt)
|
||||||
|
|
||||||
|
// unregister
|
||||||
|
w = postRegister(t, r, "/api/devices/d1/unregister")
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
got, _ = repo.Get(context.Background(), "d1")
|
||||||
|
require.Nil(t, got.RegisteredAt)
|
||||||
|
|
||||||
|
// device 全程未消失。
|
||||||
|
list, _ := repo.List(context.Background(), "demo-user")
|
||||||
|
require.Len(t, list, 1)
|
||||||
|
}
|
||||||
@ -41,6 +41,11 @@ func registerDeviceRoutes(g *gin.RouterGroup, deps Deps) {
|
|||||||
// Unpair(雛形實作:軟刪 DeviceRepo + CloseSession)
|
// Unpair(雛形實作:軟刪 DeviceRepo + CloseSession)
|
||||||
g.POST("/devices/:id/unpair", devicesUnpairHandler(deps))
|
g.POST("/devices/:id/unpair", devicesUnpairHandler(deps))
|
||||||
|
|
||||||
|
// 註冊軸(feature-device-mgmt P0,純雲端 DB 操作、UUID :id、不 proxy)。
|
||||||
|
// register:registered_at NULL→now();unregister:清 registered_at(保留列,與 unpair 分開)。
|
||||||
|
g.POST("/devices/:id/register", devicesRegisterHandler(deps))
|
||||||
|
g.POST("/devices/:id/unregister", devicesUnregisterHandler(deps))
|
||||||
|
|
||||||
// ADR-019 WP-5:localhost 直連上傳的 one-time token 取得路徑(經既有 tunnel 打
|
// ADR-019 WP-5:localhost 直連上傳的 one-time token 取得路徑(經既有 tunnel 打
|
||||||
// local-agent issue-token)。契約 path 為 /api/devices/:serial/local-upload-ticket,
|
// local-agent issue-token)。契約 path 為 /api/devices/:serial/local-upload-ticket,
|
||||||
// 但 gin/httprouter 要求同層級同名,故沿用 :id 佔位(其值語意為裝置序號 serial,
|
// 但 gin/httprouter 要求同層級同名,故沿用 :id 佔位(其值語意為裝置序號 serial,
|
||||||
|
|||||||
@ -21,6 +21,14 @@ const (
|
|||||||
ErrCodeInvalidSignature = "INVALID_SIGNATURE"
|
ErrCodeInvalidSignature = "INVALID_SIGNATURE"
|
||||||
// ErrCodeConflict 對齊 HTTP 409(例:unique 約束衝突 — 同 owner+serial 重複註冊)。
|
// ErrCodeConflict 對齊 HTTP 409(例:unique 約束衝突 — 同 owner+serial 重複註冊)。
|
||||||
ErrCodeConflict = "CONFLICT"
|
ErrCodeConflict = "CONFLICT"
|
||||||
|
// ErrCodeAlreadyRegistered 對齊 HTTP 409:對已註冊(registered_at 非 null)的 device
|
||||||
|
// 再次呼叫 register。前端據此顯示「此裝置已註冊」並 refetch(feature-device-mgmt-tdd §3.2)。
|
||||||
|
ErrCodeAlreadyRegistered = "ALREADY_REGISTERED"
|
||||||
|
// ErrCodeRepresentativeDevice 對齊 HTTP 409:對 representative device(agent 連線佔位、
|
||||||
|
// 非真實 USB)呼叫 register/unregister。註冊語意只適用真實 USB device
|
||||||
|
// (feature-device-mgmt-tdd §7.3)。與 ALREADY_REGISTERED 分開,讓 FE/TEST 能區分
|
||||||
|
// 「已註冊」與「不可註冊的裝置類型」兩種 409。
|
||||||
|
ErrCodeRepresentativeDevice = "REPRESENTATIVE_DEVICE"
|
||||||
// ErrCodeServiceUnavailable 對齊 HTTP 503。
|
// ErrCodeServiceUnavailable 對齊 HTTP 503。
|
||||||
// DB 接入塊 5.4 fail-fast 策略:PG 連線失敗 / context 逾時 → 503,讓 load balancer 知道
|
// DB 接入塊 5.4 fail-fast 策略:PG 連線失敗 / context 逾時 → 503,讓 load balancer 知道
|
||||||
// 這台不健康,而非回假資料或 500(500 會誤導為「程式 bug」,503 才是「依賴暫時不可用」)。
|
// 這台不健康,而非回假資料或 500(500 會誤導為「程式 bug」,503 才是「依賴暫時不可用」)。
|
||||||
|
|||||||
@ -45,6 +45,9 @@ func registerModelRoutes(g *gin.RouterGroup, deps Deps) {
|
|||||||
// Phase 0.9 模型庫 model 直連 FAA 下載(ADR-017 (a))。
|
// Phase 0.9 模型庫 model 直連 FAA 下載(ADR-017 (a))。
|
||||||
g.GET("/models/:id/download", modelsDownloadHandler(deps))
|
g.GET("/models/:id/download", modelsDownloadHandler(deps))
|
||||||
|
|
||||||
|
// 模型共享(library / profile / visibility / shares)。
|
||||||
|
registerModelSharingRoutes(g, deps)
|
||||||
|
|
||||||
// load-to-device 雛形先 stub(完整實作需要 presigned GET + 透過 tunnel 送指令給 local agent)
|
// load-to-device 雛形先 stub(完整實作需要 presigned GET + 透過 tunnel 送指令給 local agent)
|
||||||
g.POST("/models/:id/load-to-device", func(c *gin.Context) {
|
g.POST("/models/:id/load-to-device", func(c *gin.Context) {
|
||||||
WriteNotImplemented(c, "models.load-to-device — pending Phase 1")
|
WriteNotImplemented(c, "models.load-to-device — pending Phase 1")
|
||||||
@ -67,6 +70,9 @@ type ModelResponse struct {
|
|||||||
InputShape []int `json:"input_shape,omitempty"`
|
InputShape []int `json:"input_shape,omitempty"`
|
||||||
Classes []string `json:"classes,omitempty"`
|
Classes []string `json:"classes,omitempty"`
|
||||||
Framework string `json:"framework,omitempty"`
|
Framework string `json:"framework,omitempty"`
|
||||||
|
// Visibility:模型共享功能新增。既有前端未讀此欄不受影響(加欄相容);
|
||||||
|
// 共享 UI 讀此欄顯示公開對象 badge。既有 model 遷移後為 "private"。
|
||||||
|
Visibility string `json:"visibility,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
||||||
@ -89,6 +95,7 @@ func toModelResponse(m *model.Model) ModelResponse {
|
|||||||
InputShape: m.InputShape,
|
InputShape: m.InputShape,
|
||||||
Classes: m.Classes,
|
Classes: m.Classes,
|
||||||
Framework: m.Framework,
|
Framework: m.Framework,
|
||||||
|
Visibility: m.Visibility,
|
||||||
CreatedAt: m.CreatedAt,
|
CreatedAt: m.CreatedAt,
|
||||||
UpdatedAt: m.UpdatedAt,
|
UpdatedAt: m.UpdatedAt,
|
||||||
UploadedAt: m.UploadedAt,
|
UploadedAt: m.UploadedAt,
|
||||||
@ -559,9 +566,11 @@ func modelsDownloadHandler(deps Deps) gin.HandlerFunc {
|
|||||||
WriteDBError(c, deps.Logger, "get model", err)
|
WriteDBError(c, deps.Logger, "get model", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 第一階段 owner-only(B 分享後續階段);非 owner 回 403。
|
// 模型共享放寬:owner-only → 共享權限檢查(TDD §5 download 連帶變更)。
|
||||||
if m.OwnerUserID != userID {
|
// 與 profile 可見性共用同一 canAccessModel(single source of truth,杜絕邏輯漂移,SEC-2)。
|
||||||
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
|
// 不命中回 404(不是 403,防 enumeration,與 profile 一致,SEC-1)。
|
||||||
|
if canAccessModel(ctx, uc, m, deps.ModelRepo.GetShare) == model.AccessNone {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -218,9 +218,12 @@ func TestModelsDownload_NotFound(t *testing.T) {
|
|||||||
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
|
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModelsDownload_ForbiddenWhenNotOwner(t *testing.T) {
|
// TestModelsDownload_NotFoundWhenNoAccess 驗證模型共享後的行為改變(TDD §5 download 放寬):
|
||||||
|
// 非 owner 且無任何可見性(private model)下載,回 404(不是 403)——防 enumeration(SEC-1),
|
||||||
|
// 與 profile 的 canAccessModel 判斷一致(single source of truth)。
|
||||||
|
func TestModelsDownload_NotFoundWhenNoAccess(t *testing.T) {
|
||||||
iss := &fakeIssuer{token: "fdt_x"}
|
iss := &fakeIssuer{token: "fdt_x"}
|
||||||
// 登入 user = demo-user,但 model owner = other-user
|
// 登入 user = demo-user,但 model owner = other-user,且 model 為 private(預設)。
|
||||||
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||||
seedConvertedModel(t, repo, "m-other", "other-user", "models/other-user/job.nef")
|
seedConvertedModel(t, repo, "m-other", "other-user", "models/other-user/job.nef")
|
||||||
|
|
||||||
@ -228,9 +231,63 @@ func TestModelsDownload_ForbiddenWhenNotOwner(t *testing.T) {
|
|||||||
req := httptest.NewRequest(http.MethodGet, "/api/models/m-other/download", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/models/m-other/download", nil)
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
assert.Equal(t, http.StatusNotFound, w.Code, "private model 非 owner 應回 404(防 enumeration)")
|
||||||
assert.Contains(t, w.Body.String(), ErrCodeForbidden)
|
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
|
||||||
assert.Equal(t, 0, iss.calls, "should not issue token for non-owner")
|
assert.Equal(t, 0, iss.calls, "should not issue token when no access")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModelsDownload_PublicModelNonOwner 驗證 public model 非 owner 也能下載(共享放寬)。
|
||||||
|
func TestModelsDownload_PublicModelNonOwner(t *testing.T) {
|
||||||
|
iss := &fakeIssuer{token: "fdt_pub"}
|
||||||
|
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||||
|
seedConvertedModel(t, repo, "m-pub", "other-user", "models/other-user/pub.nef")
|
||||||
|
// owner 把 model 設為 public。
|
||||||
|
m, err := repo.Get(context.Background(), "m-pub")
|
||||||
|
require.NoError(t, err)
|
||||||
|
m.Visibility = model.VisibilityPublic
|
||||||
|
require.NoError(t, repo.Save(context.Background(), m))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/models/m-pub/download", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code, "public model 非 owner 應可下載,body=%s", w.Body.String())
|
||||||
|
assert.Equal(t, 1, iss.calls, "public model 應簽 download token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModelsDownload_SharedModelNonOwner 驗證被 restricted 分享的 grantee 也能下載。
|
||||||
|
func TestModelsDownload_SharedModelNonOwner(t *testing.T) {
|
||||||
|
iss := &fakeIssuer{token: "fdt_share"}
|
||||||
|
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||||
|
seedConvertedModel(t, repo, "m-shared", "other-user", "models/other-user/shared.nef")
|
||||||
|
// owner 把 model(private)分享給 demo-user(viewer)。
|
||||||
|
require.NoError(t, repo.UpsertShare(context.Background(), &model.ModelShare{
|
||||||
|
ModelID: "m-shared",
|
||||||
|
GranteeUserID: "demo-user",
|
||||||
|
Role: "viewer",
|
||||||
|
GrantedBy: "other-user",
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/models/m-shared/download", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code, "被分享的 grantee 應可下載,body=%s", w.Body.String())
|
||||||
|
assert.Equal(t, 1, iss.calls, "shared model 應簽 download token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModelsDownload_OwnerStillWorks 回歸:既有 owner 下載仍正常(不因放寬而退化)。
|
||||||
|
func TestModelsDownload_OwnerStillWorks(t *testing.T) {
|
||||||
|
iss := &fakeIssuer{token: "fdt_owner"}
|
||||||
|
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||||
|
seedConvertedModel(t, repo, "m-mine", "demo-user", "models/demo-user/mine.nef")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/models/m-mine/download", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code, "owner 下載應仍正常,body=%s", w.Body.String())
|
||||||
|
assert.Equal(t, 1, iss.calls)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
791
visionA-backend/internal/api/models_sharing.go
Normal file
791
visionA-backend/internal/api/models_sharing.go
Normal file
@ -0,0 +1,791 @@
|
|||||||
|
// models_sharing.go — 模型共享(Model Sharing)的 handler。
|
||||||
|
//
|
||||||
|
// 端點(對齊 api/api-model-sharing.md):
|
||||||
|
// - GET /api/models/library 共享庫列表(cursor 分頁 + sort/order/q/filter)
|
||||||
|
// - GET /api/models/:id/profile 模型 profile(權限裁剪;不命中回 404)
|
||||||
|
// - PATCH /api/models/:id/visibility 設公開對象(owner-only)
|
||||||
|
// - GET /api/models/:id/shares 列授權清單(owner-only)
|
||||||
|
// - PUT /api/models/:id/shares 加/更新 grantee 授權(owner-only)
|
||||||
|
// - DELETE /api/models/:id/shares/:userId 移除 grantee 授權(owner-only)
|
||||||
|
//
|
||||||
|
// 核心安全設計:所有可見性判斷走唯一的 canAccessModel(single source of truth,避免
|
||||||
|
// profile / download 兩處邏輯漂移,TDD §6 SEC-2);enumeration 防護一律回 404(SEC-1)。
|
||||||
|
//
|
||||||
|
// 對齊:feature-model-sharing-tdd.md §4/§5/§6、api/api-model-sharing.md。
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"visiona-backend/internal/auth"
|
||||||
|
"visiona-backend/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerModelSharingRoutes 註冊模型共享相關 routes(掛在既有 /api group,走 AuthMiddleware)。
|
||||||
|
func registerModelSharingRoutes(g *gin.RouterGroup, deps Deps) {
|
||||||
|
g.GET("/models/library", modelsLibraryHandler(deps))
|
||||||
|
g.GET("/models/:id/profile", modelsProfileHandler(deps))
|
||||||
|
g.PATCH("/models/:id/visibility", modelsSetVisibilityHandler(deps))
|
||||||
|
g.GET("/models/:id/shares", modelsListSharesHandler(deps))
|
||||||
|
g.PUT("/models/:id/shares", modelsPutShareHandler(deps))
|
||||||
|
g.DELETE("/models/:id/shares/:userId", modelsDeleteShareHandler(deps))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// canAccessModel — single source of truth(可見性判斷)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// canAccessModel 計算 uc 對 m 的有效 AccessLevel。這是 profile / download / (未來) load 的
|
||||||
|
// 唯一權限判斷入口——絕不在別處複製一份可見性邏輯(TDD §6 SEC-2)。
|
||||||
|
//
|
||||||
|
// 判斷順序(取最高權限):
|
||||||
|
// 1. owner(m.OwnerUserID == uc.UserID)→ AccessOwner
|
||||||
|
// 2. share 命中 → editor / viewer(依 share.role)
|
||||||
|
// 3. visibility=public → viewer
|
||||||
|
// 4. visibility=tenant 且 owner.org_id == uc.OrgID 且兩者皆非空 → viewer(SEC-4 tenant 邊界)
|
||||||
|
// 5. 皆不命中 → AccessNone
|
||||||
|
//
|
||||||
|
// preset 由呼叫端(handler)在進 canAccessModel 前處理(preset 無 owner、公用),不走此函式。
|
||||||
|
//
|
||||||
|
// shareLookup 為查 (modelID, granteeUserID) 分享的函式(注入以利測試 / 共用 repo);
|
||||||
|
// 傳 nil 時視為「無任何分享」(僅 visibility 判斷)。
|
||||||
|
func canAccessModel(ctx context.Context, uc *auth.UserContext, m *model.Model,
|
||||||
|
shareLookup func(ctx context.Context, modelID, granteeUserID string) (*model.ModelShare, error),
|
||||||
|
) model.AccessLevel {
|
||||||
|
if uc == nil || uc.UserID == "" || m == nil {
|
||||||
|
return model.AccessNone
|
||||||
|
}
|
||||||
|
// 1. owner
|
||||||
|
if m.OwnerUserID == uc.UserID {
|
||||||
|
return model.AccessOwner
|
||||||
|
}
|
||||||
|
// 2. share 命中
|
||||||
|
if shareLookup != nil {
|
||||||
|
if s, err := shareLookup(ctx, m.ID, uc.UserID); err == nil && s != nil {
|
||||||
|
if s.Role == "editor" {
|
||||||
|
return model.AccessEditor
|
||||||
|
}
|
||||||
|
return model.AccessViewer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. public
|
||||||
|
if m.Visibility == model.VisibilityPublic {
|
||||||
|
return model.AccessViewer
|
||||||
|
}
|
||||||
|
// 4. tenant(兩者皆非空才可能命中;空 org 一律不落 tenant 可見)
|
||||||
|
if m.Visibility == model.VisibilityTenant && uc.OrgID != "" && m.OwnerUserID != "" {
|
||||||
|
if ownerOrg := ownerOrgOf(ctx, m); ownerOrg != "" && ownerOrg == uc.OrgID {
|
||||||
|
return model.AccessViewer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model.AccessNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerOrgOf 是 tenant 判斷取 owner.org_id 的鉤子。
|
||||||
|
//
|
||||||
|
// 目前 OIDC 不帶 org claim(middleware 未填 UserContext.OrgID,恆空),故 canAccessModel
|
||||||
|
// 第 4 步的前置 `uc.OrgID != ""` 一定為 false、永遠短路——本函式實務上不會被呼叫到。
|
||||||
|
// 保留為明確的擴充點:待 OIDC 補 org claim + repository 提供 owner.org_id 後在此接線。
|
||||||
|
// 現階段回空字串(= tenant 不命中,安全預設)。
|
||||||
|
func ownerOrgOf(_ context.Context, _ *model.Model) string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// GET /api/models/library
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// LibraryItemResponse 是共享庫列表的一列 DTO(api §1)。
|
||||||
|
//
|
||||||
|
// owner 只揭露 id/name/is_me(不揭露 owner email);不含 storage_key / faa_object_key(SEC-3)。
|
||||||
|
type LibraryItemResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
TargetChip string `json:"target_chip,omitempty"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
Owner OwnerResponse `json:"owner"`
|
||||||
|
SharedWithMe bool `json:"shared_with_me"`
|
||||||
|
MyAccess string `json:"my_access"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnerResponse 是裁剪後的 owner 資訊(絕不含 email)。
|
||||||
|
type OwnerResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
IsMe bool `json:"is_me"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryResponse 是 GET /api/models/library 的 data payload。
|
||||||
|
type LibraryResponse struct {
|
||||||
|
Items []LibraryItemResponse `json:"items"`
|
||||||
|
NextCursor string `json:"next_cursor,omitempty"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
libraryDefaultLimit = 20
|
||||||
|
libraryMaxLimit = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// modelsLibraryHandler 實作 GET /api/models/library。
|
||||||
|
func modelsLibraryHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if deps.ModelRepo == nil {
|
||||||
|
// 無 repo(最小骨架):至少回 preset(公用、所有人可見)。
|
||||||
|
WriteSuccess(c, http.StatusOK, LibraryResponse{Items: presetLibraryItems(c), HasMore: false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uc, ok := UserContextFrom(c)
|
||||||
|
if !ok || uc.UserID == "" {
|
||||||
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
|
"missing user context (auth middleware misconfigured?)", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q, verr := parseLibraryQuery(c, uc)
|
||||||
|
if verr != "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, verr, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
items, hasMore, err := deps.ModelRepo.Library(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
WriteDBError(c, deps.Logger, "list model library", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := LibraryResponse{
|
||||||
|
Items: make([]LibraryItemResponse, 0, len(items)),
|
||||||
|
HasMore: hasMore,
|
||||||
|
}
|
||||||
|
for _, it := range items {
|
||||||
|
resp.Items = append(resp.Items, toLibraryItemResponse(it, uc.UserID))
|
||||||
|
}
|
||||||
|
if hasMore && len(items) > 0 {
|
||||||
|
last := items[len(items)-1].Model
|
||||||
|
resp.NextCursor = encodeCursor(q.Sort, last)
|
||||||
|
}
|
||||||
|
WriteSuccess(c, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLibraryQuery 解析 + 驗證 query 參數,回傳 model.LibraryQuery;驗證失敗回錯誤訊息。
|
||||||
|
func parseLibraryQuery(c *gin.Context, uc *auth.UserContext) (model.LibraryQuery, string) {
|
||||||
|
q := model.LibraryQuery{
|
||||||
|
UserID: uc.UserID,
|
||||||
|
UserOrgID: uc.OrgID, // OIDC 現況恆空 → tenant 不命中
|
||||||
|
TargetChip: c.Query("target_chip"),
|
||||||
|
Source: c.Query("source"),
|
||||||
|
Q: strings.TrimSpace(c.Query("q")),
|
||||||
|
Limit: libraryDefaultLimit,
|
||||||
|
}
|
||||||
|
|
||||||
|
// limit:clamp 到 1–100。
|
||||||
|
if raw := c.Query("limit"); raw != "" {
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return q, "limit must be an integer"
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
if n > libraryMaxLimit {
|
||||||
|
n = libraryMaxLimit
|
||||||
|
}
|
||||||
|
q.Limit = n
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort 白名單。
|
||||||
|
switch c.Query("sort") {
|
||||||
|
case "", "created_at":
|
||||||
|
q.Sort = "created_at"
|
||||||
|
case "name":
|
||||||
|
q.Sort = "name"
|
||||||
|
case "file_size":
|
||||||
|
q.Sort = "file_size"
|
||||||
|
default:
|
||||||
|
return q, "sort must be one of: created_at, name, file_size"
|
||||||
|
}
|
||||||
|
// order 白名單。
|
||||||
|
switch c.Query("order") {
|
||||||
|
case "", "desc":
|
||||||
|
q.Order = "desc"
|
||||||
|
case "asc":
|
||||||
|
q.Order = "asc"
|
||||||
|
default:
|
||||||
|
return q, "order must be asc or desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibility filter(僅 public / tenant 有意義;其他忽略)。
|
||||||
|
switch c.Query("visibility") {
|
||||||
|
case model.VisibilityPublic, model.VisibilityTenant:
|
||||||
|
q.Visibility = c.Query("visibility")
|
||||||
|
}
|
||||||
|
|
||||||
|
// owned filter(true/false)。
|
||||||
|
if raw := c.Query("owned"); raw != "" {
|
||||||
|
b, err := strconv.ParseBool(raw)
|
||||||
|
if err != nil {
|
||||||
|
return q, "owned must be a boolean"
|
||||||
|
}
|
||||||
|
q.Owned = &b
|
||||||
|
}
|
||||||
|
|
||||||
|
// cursor(不透明 base64)。
|
||||||
|
if raw := c.Query("cursor"); raw != "" {
|
||||||
|
cur, err := decodeCursor(raw)
|
||||||
|
if err != nil {
|
||||||
|
return q, "invalid cursor"
|
||||||
|
}
|
||||||
|
q.Cursor = cur
|
||||||
|
}
|
||||||
|
|
||||||
|
return q, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// toLibraryItemResponse 把 LibraryItem 轉 DTO。my_access:owner 由 is_me 覆寫為 owner。
|
||||||
|
func toLibraryItemResponse(it *model.LibraryItem, userID string) LibraryItemResponse {
|
||||||
|
m := it.Model
|
||||||
|
status := "pending"
|
||||||
|
if m.UploadedAt != nil {
|
||||||
|
status = "ready"
|
||||||
|
}
|
||||||
|
isMe := m.OwnerUserID == userID
|
||||||
|
access := it.MyAccess
|
||||||
|
if isMe {
|
||||||
|
access = model.AccessOwner
|
||||||
|
}
|
||||||
|
return LibraryItemResponse{
|
||||||
|
ID: m.ID,
|
||||||
|
Name: m.Name,
|
||||||
|
Description: m.Description,
|
||||||
|
TargetChip: m.TargetChip,
|
||||||
|
FileSize: m.FileSize,
|
||||||
|
Source: m.Source,
|
||||||
|
Status: status,
|
||||||
|
Visibility: m.Visibility,
|
||||||
|
Owner: OwnerResponse{
|
||||||
|
ID: m.OwnerUserID,
|
||||||
|
Name: it.OwnerName,
|
||||||
|
IsMe: isMe,
|
||||||
|
},
|
||||||
|
SharedWithMe: it.SharedWithMe,
|
||||||
|
MyAccess: access,
|
||||||
|
CreatedAt: m.CreatedAt,
|
||||||
|
UpdatedAt: m.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// presetLibraryItems 把 preset 轉成 library DTO(公用、is_me=false、my_access=viewer)。
|
||||||
|
func presetLibraryItems(c *gin.Context) []LibraryItemResponse {
|
||||||
|
presets := model.PresetModels()
|
||||||
|
out := make([]LibraryItemResponse, 0, len(presets))
|
||||||
|
for _, m := range presets {
|
||||||
|
status := "ready"
|
||||||
|
out = append(out, LibraryItemResponse{
|
||||||
|
ID: m.ID,
|
||||||
|
Name: m.Name,
|
||||||
|
TargetChip: m.TargetChip,
|
||||||
|
FileSize: m.FileSize,
|
||||||
|
Source: m.Source,
|
||||||
|
Status: status,
|
||||||
|
Visibility: m.Visibility,
|
||||||
|
Owner: OwnerResponse{ID: "", Name: "system", IsMe: false},
|
||||||
|
MyAccess: model.AccessViewer,
|
||||||
|
CreatedAt: m.CreatedAt,
|
||||||
|
UpdatedAt: m.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// cursor 編/解碼(不透明 base64)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// cursorPayload 是 cursor 的 JSON 內容(前端當黑箱)。
|
||||||
|
type cursorPayload struct {
|
||||||
|
V string `json:"v"` // 排序值
|
||||||
|
ID string `json:"id"` // tie-breaker
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeCursor 依 sort 欄位取 last item 的排序值,組不透明 base64 游標。
|
||||||
|
func encodeCursor(sortField string, last *model.Model) string {
|
||||||
|
var v string
|
||||||
|
switch sortField {
|
||||||
|
case "name":
|
||||||
|
v = last.Name
|
||||||
|
case "file_size":
|
||||||
|
v = strconv.FormatInt(last.FileSize, 10)
|
||||||
|
default: // created_at
|
||||||
|
v = last.CreatedAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(cursorPayload{V: v, ID: last.ID})
|
||||||
|
return base64.RawURLEncoding.EncodeToString(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeCursor 解 base64 游標;格式錯誤回 error(handler 轉 400)。
|
||||||
|
func decodeCursor(s string) (*model.Cursor, error) {
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var p cursorPayload
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.ID == "" {
|
||||||
|
return nil, errors.New("cursor missing id")
|
||||||
|
}
|
||||||
|
return &model.Cursor{SortValue: p.V, ID: p.ID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// GET /api/models/:id/profile
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// ProfileResponse 是 GET /api/models/:id/profile 的 data payload(api §2)。
|
||||||
|
//
|
||||||
|
// 絕不含 storage_key / faa_object_key / owner email / file_checksum(SEC-3)。
|
||||||
|
type ProfileResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
TargetChip string `json:"target_chip,omitempty"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
InputShape []int `json:"input_shape,omitempty"`
|
||||||
|
Classes []string `json:"classes,omitempty"`
|
||||||
|
Framework string `json:"framework,omitempty"`
|
||||||
|
Owner OwnerResponse `json:"owner"`
|
||||||
|
MyAccess string `json:"my_access"`
|
||||||
|
CanDownload bool `json:"can_download"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsProfileHandler 實作 GET /api/models/:id/profile。
|
||||||
|
//
|
||||||
|
// 可見性檢查為第一步;不命中回 404(不是 403,防 enumeration,SEC-1)。
|
||||||
|
func modelsProfileHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// preset 公用、任何登入 user 可見。
|
||||||
|
if pm, ok := model.PresetByID(id); ok {
|
||||||
|
WriteSuccess(c, http.StatusOK, presetProfileResponse(pm))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if deps.ModelRepo == nil {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uc, ok := UserContextFrom(c)
|
||||||
|
if !ok || uc.UserID == "" {
|
||||||
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
|
"missing user context (auth middleware misconfigured?)", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
m, ownerName, err := deps.ModelRepo.GetWithOwner(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteDBError(c, deps.Logger, "get model profile", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
access := canAccessModel(ctx, uc, m, deps.ModelRepo.GetShare)
|
||||||
|
if access == model.AccessNone {
|
||||||
|
// enumeration 防護:不揭露「id 存在但你沒權限」,回 404 與「不存在」無法區分。
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(c, http.StatusOK, toProfileResponse(m, ownerName, uc.UserID, access))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toProfileResponse 組 profile DTO(依 access 裁剪;不揭露內部 key)。
|
||||||
|
// ownerName 由 GetWithOwner join users 帶出(api §2 owner.name);owner 未設 name 時為空。
|
||||||
|
func toProfileResponse(m *model.Model, ownerName, userID string, access model.AccessLevel) ProfileResponse {
|
||||||
|
status := "pending"
|
||||||
|
if m.UploadedAt != nil {
|
||||||
|
status = "ready"
|
||||||
|
}
|
||||||
|
return ProfileResponse{
|
||||||
|
ID: m.ID,
|
||||||
|
Name: m.Name,
|
||||||
|
Description: m.Description,
|
||||||
|
TargetChip: m.TargetChip,
|
||||||
|
FileSize: m.FileSize,
|
||||||
|
Source: m.Source,
|
||||||
|
Status: status,
|
||||||
|
Visibility: m.Visibility,
|
||||||
|
InputShape: m.InputShape,
|
||||||
|
Classes: m.Classes,
|
||||||
|
Framework: m.Framework,
|
||||||
|
Owner: OwnerResponse{
|
||||||
|
ID: m.OwnerUserID,
|
||||||
|
Name: ownerName, // join users.name 帶出(SEC-3 白名單:只揭露 id/name/is_me,不含 email)
|
||||||
|
IsMe: m.OwnerUserID == userID,
|
||||||
|
},
|
||||||
|
MyAccess: access,
|
||||||
|
CanDownload: access != model.AccessNone,
|
||||||
|
CreatedAt: m.CreatedAt,
|
||||||
|
UpdatedAt: m.UpdatedAt,
|
||||||
|
UploadedAt: m.UploadedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// presetProfileResponse 組 preset 的 profile(公用、viewer、可下載)。
|
||||||
|
func presetProfileResponse(m *model.Model) ProfileResponse {
|
||||||
|
return ProfileResponse{
|
||||||
|
ID: m.ID,
|
||||||
|
Name: m.Name,
|
||||||
|
Description: m.Description,
|
||||||
|
TargetChip: m.TargetChip,
|
||||||
|
FileSize: m.FileSize,
|
||||||
|
Source: m.Source,
|
||||||
|
Status: "ready",
|
||||||
|
Visibility: model.VisibilityPublic,
|
||||||
|
InputShape: m.InputShape,
|
||||||
|
Classes: m.Classes,
|
||||||
|
Framework: m.Framework,
|
||||||
|
Owner: OwnerResponse{ID: "", Name: "system", IsMe: false},
|
||||||
|
MyAccess: model.AccessViewer,
|
||||||
|
CanDownload: true,
|
||||||
|
CreatedAt: m.CreatedAt,
|
||||||
|
UpdatedAt: m.UpdatedAt,
|
||||||
|
UploadedAt: m.UploadedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// PATCH /api/models/:id/visibility
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// SetVisibilityRequest 是 PATCH visibility 的 body。
|
||||||
|
type SetVisibilityRequest struct {
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVisibilityResponse 是 PATCH visibility 的 data payload。
|
||||||
|
type SetVisibilityResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsSetVisibilityHandler 實作 PATCH /api/models/:id/visibility(owner-only)。
|
||||||
|
func modelsSetVisibilityHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if deps.ModelRepo == nil {
|
||||||
|
WriteNotImplemented(c, "model repo not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// preset 不可改 visibility(公用、無 owner)。
|
||||||
|
if model.IsPresetID(id) {
|
||||||
|
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "preset visibility is fixed", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uc, ok := UserContextFrom(c)
|
||||||
|
if !ok || uc.UserID == "" {
|
||||||
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
|
"missing user context (auth middleware misconfigured?)", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req SetVisibilityRequest
|
||||||
|
if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "invalid JSON: "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !model.IsValidVisibility(req.Visibility) {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||||||
|
"visibility must be one of: private, tenant, public",
|
||||||
|
[]FieldError{{Field: "visibility", Message: "invalid value"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// tenant 但 user 無 org → 400(無租戶歸屬不能設 tenant 可見,api §3)。
|
||||||
|
if req.Visibility == model.VisibilityTenant && uc.OrgID == "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||||||
|
"cannot set tenant visibility without an organization",
|
||||||
|
[]FieldError{{Field: "visibility", Message: "no org membership"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
m, err := deps.ModelRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteDBError(c, deps.Logger, "get model", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// owner-only(SEC-5)。非 owner 回 403(此為「改權限」動作,回 403 合理——
|
||||||
|
// 與 profile/download 的 enumeration 情境不同:能走到這代表 model 存在且是寫入意圖)。
|
||||||
|
if m.OwnerUserID != uc.UserID {
|
||||||
|
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 未 ready(未 finalize)不允許公開(api §3 409)。
|
||||||
|
if req.Visibility != model.VisibilityPrivate && m.UploadedAt == nil {
|
||||||
|
WriteError(c, http.StatusConflict, ErrCodeConflict,
|
||||||
|
"model must be ready (finalized) before it can be shared", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Visibility = req.Visibility
|
||||||
|
now := time.Now().UTC()
|
||||||
|
m.UpdatedAt = now
|
||||||
|
if err := deps.ModelRepo.Save(ctx, m); err != nil {
|
||||||
|
WriteDBError(c, deps.Logger, "save model visibility", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logOrDefault(deps.Logger).Info("models: visibility updated",
|
||||||
|
"model_id", m.ID,
|
||||||
|
"user_id", uc.UserID,
|
||||||
|
"visibility", req.Visibility,
|
||||||
|
"request_id", RequestIDFrom(c))
|
||||||
|
|
||||||
|
WriteSuccess(c, http.StatusOK, SetVisibilityResponse{
|
||||||
|
ID: m.ID,
|
||||||
|
Visibility: m.Visibility,
|
||||||
|
UpdatedAt: m.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// GET/PUT/DELETE /api/models/:id/shares — restricted 分享授權管理(owner-only)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// ShareResponse 是一筆分享授權 DTO(owner 檢視清單用)。
|
||||||
|
//
|
||||||
|
// 只揭露 grantee id + role + 授權時間;不揭露 grantee email(同 owner email 不揭露原則)。
|
||||||
|
type ShareResponse struct {
|
||||||
|
GranteeUserID string `json:"grantee_user_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
GrantedBy string `json:"granted_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsListSharesHandler 實作 GET /api/models/:id/shares(owner-only)。
|
||||||
|
func modelsListSharesHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
m, uc, ok := requireOwnedModel(c, deps)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
shares, err := deps.ModelRepo.ListShares(ctx, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
WriteDBError(c, deps.Logger, "list model shares", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]ShareResponse, 0, len(shares))
|
||||||
|
for _, s := range shares {
|
||||||
|
out = append(out, ShareResponse{
|
||||||
|
GranteeUserID: s.GranteeUserID,
|
||||||
|
Role: s.Role,
|
||||||
|
GrantedBy: s.GrantedBy,
|
||||||
|
CreatedAt: s.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = uc
|
||||||
|
WriteSuccess(c, http.StatusOK, gin.H{"shares": out})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutShareRequest 是 PUT shares 的 body(加/更新一個 grantee 授權)。
|
||||||
|
type PutShareRequest struct {
|
||||||
|
GranteeUserID string `json:"grantee_user_id"`
|
||||||
|
Role string `json:"role,omitempty"` // 'viewer'(預設)| 'editor'
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsPutShareHandler 實作 PUT /api/models/:id/shares(owner-only;加/更新授權)。
|
||||||
|
func modelsPutShareHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
m, uc, ok := requireOwnedModel(c, deps)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req PutShareRequest
|
||||||
|
if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "invalid JSON: "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.GranteeUserID = strings.TrimSpace(req.GranteeUserID)
|
||||||
|
if req.GranteeUserID == "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||||||
|
"grantee_user_id is required",
|
||||||
|
[]FieldError{{Field: "grantee_user_id", Message: "cannot be empty"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 不能分享給自己(owner 已有完整權限)。
|
||||||
|
if req.GranteeUserID == uc.UserID {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||||||
|
"cannot share a model with its owner", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
role := req.Role
|
||||||
|
if role == "" {
|
||||||
|
role = "viewer"
|
||||||
|
}
|
||||||
|
if role != "viewer" && role != "editor" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||||||
|
"role must be viewer or editor",
|
||||||
|
[]FieldError{{Field: "role", Message: "invalid value"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := deps.ModelRepo.UpsertShare(ctx, &model.ModelShare{
|
||||||
|
ModelID: m.ID,
|
||||||
|
GranteeUserID: req.GranteeUserID,
|
||||||
|
Role: role,
|
||||||
|
GrantedBy: uc.UserID,
|
||||||
|
}); err != nil {
|
||||||
|
WriteDBError(c, deps.Logger, "upsert model share", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logOrDefault(deps.Logger).Info("models: share granted",
|
||||||
|
"model_id", m.ID,
|
||||||
|
"user_id", uc.UserID,
|
||||||
|
"grantee", req.GranteeUserID,
|
||||||
|
"role", role,
|
||||||
|
"request_id", RequestIDFrom(c))
|
||||||
|
|
||||||
|
WriteSuccess(c, http.StatusOK, ShareResponse{
|
||||||
|
GranteeUserID: req.GranteeUserID,
|
||||||
|
Role: role,
|
||||||
|
GrantedBy: uc.UserID,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsDeleteShareHandler 實作 DELETE /api/models/:id/shares/:userId(owner-only;撤銷授權)。
|
||||||
|
func modelsDeleteShareHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
m, uc, ok := requireOwnedModel(c, deps)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
grantee := c.Param("userId")
|
||||||
|
if grantee == "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "user id required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := deps.ModelRepo.DeleteShare(ctx, m.ID, grantee); err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "share not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteDBError(c, deps.Logger, "delete model share", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logOrDefault(deps.Logger).Info("models: share revoked",
|
||||||
|
"model_id", m.ID,
|
||||||
|
"user_id", uc.UserID,
|
||||||
|
"grantee", grantee,
|
||||||
|
"request_id", RequestIDFrom(c))
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireOwnedModel 是分享授權管理 API 的共用前置:取 model + 驗 owner-only。
|
||||||
|
//
|
||||||
|
// 回傳 (model, userContext, ok);ok=false 時已寫好 error response,呼叫端直接 return。
|
||||||
|
// preset 不可管理分享(無 owner)→ 403。
|
||||||
|
func requireOwnedModel(c *gin.Context, deps Deps) (*model.Model, *auth.UserContext, bool) {
|
||||||
|
if deps.ModelRepo == nil {
|
||||||
|
WriteNotImplemented(c, "model repo not configured")
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
if model.IsPresetID(id) {
|
||||||
|
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "preset models cannot be shared", nil)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
uc, ok := UserContextFrom(c)
|
||||||
|
if !ok || uc.UserID == "" {
|
||||||
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
|
"missing user context (auth middleware misconfigured?)", nil)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
m, err := deps.ModelRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
WriteDBError(c, deps.Logger, "get model", err)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
if m.OwnerUserID != uc.UserID {
|
||||||
|
// 分享授權管理是 owner-only 寫入意圖:非 owner 回 403。
|
||||||
|
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
return m, uc, true
|
||||||
|
}
|
||||||
514
visionA-backend/internal/api/models_sharing_test.go
Normal file
514
visionA-backend/internal/api/models_sharing_test.go
Normal file
@ -0,0 +1,514 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"visiona-backend/internal/auth"
|
||||||
|
"visiona-backend/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// canAccessModelForTest 以 userID(無 org)包一層 canAccessModel,方便單元測試。
|
||||||
|
func canAccessModelForTest(ctx context.Context, userID string, m *model.Model,
|
||||||
|
shareLookup func(context.Context, string, string) (*model.ModelShare, error),
|
||||||
|
) model.AccessLevel {
|
||||||
|
return canAccessModel(ctx, &auth.UserContext{UserID: userID}, m, shareLookup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// fixture
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// newSharingFixture 建一個「以 userID 身份登入」的模型共享 route fixture。
|
||||||
|
func newSharingFixture(t *testing.T, userID string) (*gin.Engine, *model.InMemoryRepository) {
|
||||||
|
t.Helper()
|
||||||
|
repo := model.NewInMemoryRepository()
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(RequestIDMiddleware())
|
||||||
|
r.Use(injectStaticUserContext(userID, ""))
|
||||||
|
g := r.Group("/api")
|
||||||
|
registerModelRoutes(g, Deps{
|
||||||
|
ModelRepo: repo,
|
||||||
|
MaxUploadSizeMB: 10,
|
||||||
|
})
|
||||||
|
return r, repo
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedReadyModel 塞一個 ready(已 finalize)的 model,指定 owner + visibility。
|
||||||
|
func seedReadyModel(t *testing.T, repo *model.InMemoryRepository, id, owner, visibility string) *model.Model {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
m := &model.Model{
|
||||||
|
ID: id,
|
||||||
|
OwnerUserID: owner,
|
||||||
|
Name: "model-" + id,
|
||||||
|
StorageKey: "models/" + owner + "/" + id + ".nef",
|
||||||
|
FileSize: 1024,
|
||||||
|
Source: model.SourceUploaded,
|
||||||
|
Visibility: visibility,
|
||||||
|
UploadedAt: &now, // ready
|
||||||
|
}
|
||||||
|
require.NoError(t, repo.Save(context.Background(), m))
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeData 解 envelope 的 data 到 target。
|
||||||
|
func decodeData(t *testing.T, body []byte, target any) {
|
||||||
|
t.Helper()
|
||||||
|
var sb SuccessBody
|
||||||
|
require.NoError(t, json.Unmarshal(body, &sb))
|
||||||
|
raw, err := json.Marshal(sb.Data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, json.Unmarshal(raw, target))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// GET /api/models/library — 可見性 matrix
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// TestLibrary_VisibilityMatrix 驗證共享庫只列可見 model:我的 + public + shared,
|
||||||
|
// 不含別人的 private(TDD §4.1 predicate)。
|
||||||
|
func TestLibrary_VisibilityMatrix(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
|
||||||
|
seedReadyModel(t, repo, "mine-priv", "me", model.VisibilityPrivate) // 我的 private → 可見
|
||||||
|
seedReadyModel(t, repo, "other-priv", "other", model.VisibilityPrivate) // 別人 private → 不可見
|
||||||
|
seedReadyModel(t, repo, "other-pub", "other", model.VisibilityPublic) // 別人 public → 可見
|
||||||
|
shared := seedReadyModel(t, repo, "other-shared", "other", model.VisibilityPrivate)
|
||||||
|
require.NoError(t, repo.UpsertShare(context.Background(), &model.ModelShare{
|
||||||
|
ModelID: shared.ID, GranteeUserID: "me", Role: "viewer", GrantedBy: "other",
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/models/library?limit=100", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||||
|
|
||||||
|
var resp LibraryResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
|
got := map[string]LibraryItemResponse{}
|
||||||
|
for _, it := range resp.Items {
|
||||||
|
got[it.ID] = it
|
||||||
|
}
|
||||||
|
assert.Contains(t, got, "mine-priv", "我的 private 應可見")
|
||||||
|
assert.Contains(t, got, "other-pub", "別人 public 應可見")
|
||||||
|
assert.Contains(t, got, "other-shared", "分享給我的應可見")
|
||||||
|
assert.NotContains(t, got, "other-priv", "別人 private 不應可見")
|
||||||
|
|
||||||
|
// my_access / is_me / shared_with_me 正確。
|
||||||
|
assert.Equal(t, model.AccessOwner, got["mine-priv"].MyAccess)
|
||||||
|
assert.True(t, got["mine-priv"].Owner.IsMe)
|
||||||
|
assert.Equal(t, model.AccessViewer, got["other-pub"].MyAccess)
|
||||||
|
assert.False(t, got["other-pub"].Owner.IsMe)
|
||||||
|
assert.True(t, got["other-shared"].SharedWithMe, "分享給我的應標 shared_with_me")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibrary_ExcludesNotReady 驗證未 finalize(pending)的 model 不進共享庫。
|
||||||
|
func TestLibrary_ExcludesNotReady(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
// pending model(UploadedAt=nil)。
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||||||
|
ID: "pending", OwnerUserID: "me", Name: "p", StorageKey: "k",
|
||||||
|
FileSize: 1, Source: model.SourceUploaded, Visibility: model.VisibilityPublic,
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/models/library", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
var resp LibraryResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &resp)
|
||||||
|
assert.Empty(t, resp.Items, "pending model 不應進共享庫")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibrary_OwnedFilter 驗證 owned=true 只回我的、owned=false 只回別人分享/公開的。
|
||||||
|
func TestLibrary_OwnedFilter(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "mine", "me", model.VisibilityPrivate)
|
||||||
|
seedReadyModel(t, repo, "pub", "other", model.VisibilityPublic)
|
||||||
|
|
||||||
|
// owned=true
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/library?owned=true", nil))
|
||||||
|
var mineOnly LibraryResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &mineOnly)
|
||||||
|
require.Len(t, mineOnly.Items, 1)
|
||||||
|
assert.Equal(t, "mine", mineOnly.Items[0].ID)
|
||||||
|
|
||||||
|
// owned=false
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/library?owned=false", nil))
|
||||||
|
var othersOnly LibraryResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &othersOnly)
|
||||||
|
require.Len(t, othersOnly.Items, 1)
|
||||||
|
assert.Equal(t, "pub", othersOnly.Items[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibrary_SearchQ 驗證 q 搜尋 name。
|
||||||
|
func TestLibrary_SearchQ(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
m1 := seedReadyModel(t, repo, "a", "me", model.VisibilityPrivate)
|
||||||
|
m1.Name = "yolov5-detect"
|
||||||
|
require.NoError(t, repo.Save(context.Background(), m1))
|
||||||
|
m2 := seedReadyModel(t, repo, "b", "me", model.VisibilityPrivate)
|
||||||
|
m2.Name = "resnet-classify"
|
||||||
|
require.NoError(t, repo.Save(context.Background(), m2))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/library?q=yolo", nil))
|
||||||
|
var resp LibraryResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &resp)
|
||||||
|
require.Len(t, resp.Items, 1)
|
||||||
|
assert.Equal(t, "a", resp.Items[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibrary_CursorPagination 驗證 cursor 分頁不重複、不遺漏。
|
||||||
|
func TestLibrary_CursorPagination(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
m := seedReadyModel(t, repo, string(rune('a'+i)), "me", model.VisibilityPrivate)
|
||||||
|
// 讓 created_at 有序(sort=name 更穩定,用 name 分頁)。
|
||||||
|
_ = m
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
cursor := ""
|
||||||
|
pages := 0
|
||||||
|
for {
|
||||||
|
url := "/api/models/library?limit=2&sort=name&order=asc"
|
||||||
|
if cursor != "" {
|
||||||
|
url += "&cursor=" + cursor
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, url, nil))
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
var resp LibraryResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &resp)
|
||||||
|
for _, it := range resp.Items {
|
||||||
|
assert.False(t, seen[it.ID], "id %s 重複出現於分頁", it.ID)
|
||||||
|
seen[it.ID] = true
|
||||||
|
}
|
||||||
|
pages++
|
||||||
|
require.Less(t, pages, 10, "分頁不應無限迴圈")
|
||||||
|
if !resp.HasMore {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cursor = resp.NextCursor
|
||||||
|
require.NotEmpty(t, cursor, "has_more=true 時應有 next_cursor")
|
||||||
|
}
|
||||||
|
assert.Len(t, seen, 5, "所有 model 應被分頁完整走過一次")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibrary_InvalidParams 驗證非法 sort / limit / cursor 回 400。
|
||||||
|
func TestLibrary_InvalidParams(t *testing.T) {
|
||||||
|
r, _ := newSharingFixture(t, "me")
|
||||||
|
for _, url := range []string{
|
||||||
|
"/api/models/library?sort=bogus",
|
||||||
|
"/api/models/library?limit=abc",
|
||||||
|
"/api/models/library?order=sideways",
|
||||||
|
"/api/models/library?cursor=!!!notbase64!!!",
|
||||||
|
"/api/models/library?owned=maybe",
|
||||||
|
} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, url, nil))
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code, "url=%s should be 400, body=%s", url, w.Body.String())
|
||||||
|
assert.Contains(t, w.Body.String(), ErrCodeValidationFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// GET /api/models/:id/profile
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// TestProfile_PublicVisibleToNonOwner 驗證 public model 非 owner 可看 profile,
|
||||||
|
// 且 owner.name 有帶出(Minor-1:profile join owner name,對齊 api §2)。
|
||||||
|
func TestProfile_PublicVisibleToNonOwner(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "pub", "other", model.VisibilityPublic)
|
||||||
|
repo.SetUserName("other", "Alice") // owner 顯示名
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/pub/profile", nil))
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||||
|
|
||||||
|
var p ProfileResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &p)
|
||||||
|
assert.Equal(t, model.AccessViewer, p.MyAccess)
|
||||||
|
assert.True(t, p.CanDownload)
|
||||||
|
assert.False(t, p.Owner.IsMe)
|
||||||
|
assert.Equal(t, "other", p.Owner.ID)
|
||||||
|
assert.Equal(t, "Alice", p.Owner.Name, "profile 應帶出 owner name(Minor-1)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProfile_PrivateHiddenReturns404 驗證別人 private model → profile 回 404(防 enumeration)。
|
||||||
|
func TestProfile_PrivateHiddenReturns404(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "secret", "other", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/secret/profile", nil))
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code, "無權限應回 404,不是 403")
|
||||||
|
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProfile_NonExistentReturns404Same 驗證不存在的 id 與無權限的 id 回相同 404(enumeration 防護)。
|
||||||
|
func TestProfile_NonExistentReturns404Same(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "secret", "other", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
wHidden := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(wHidden, httptest.NewRequest(http.MethodGet, "/api/models/secret/profile", nil))
|
||||||
|
wMissing := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(wMissing, httptest.NewRequest(http.MethodGet, "/api/models/does-not-exist/profile", nil))
|
||||||
|
|
||||||
|
assert.Equal(t, wMissing.Code, wHidden.Code, "無權限與不存在應回相同 status")
|
||||||
|
// body 除了 request_id 外結構一致(都是 NOT_FOUND / model not found)。
|
||||||
|
assert.Contains(t, wHidden.Body.String(), "model not found")
|
||||||
|
assert.Contains(t, wMissing.Body.String(), "model not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProfile_NoLeakInternalKeys 驗證 profile 不洩漏 storage_key / faa_object_key / owner email(SEC-3)。
|
||||||
|
func TestProfile_NoLeakInternalKeys(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
m := seedReadyModel(t, repo, "pub", "other", model.VisibilityPublic)
|
||||||
|
m.FAAObjectKey = "models/other/secret-object-key.nef"
|
||||||
|
m.FileChecksum = "sha256-secret"
|
||||||
|
require.NoError(t, repo.Save(context.Background(), m))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/pub/profile", nil))
|
||||||
|
body := w.Body.String()
|
||||||
|
assert.NotContains(t, body, "secret-object-key", "不應洩漏 faa_object_key")
|
||||||
|
assert.NotContains(t, body, "storage_key", "不應輸出 storage_key 欄")
|
||||||
|
assert.NotContains(t, body, m.StorageKey, "不應洩漏 storage_key 值")
|
||||||
|
assert.NotContains(t, body, "sha256-secret", "不應洩漏 file_checksum")
|
||||||
|
assert.NotContains(t, body, "email", "不應輸出 owner email 欄")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProfile_Preset 驗證 preset profile 任何登入 user 可看。
|
||||||
|
func TestProfile_Preset(t *testing.T) {
|
||||||
|
r, _ := newSharingFixture(t, "me")
|
||||||
|
presets := model.PresetModels()
|
||||||
|
require.NotEmpty(t, presets)
|
||||||
|
presetID := presets[0].ID
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/"+presetID+"/profile", nil))
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||||
|
var p ProfileResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &p)
|
||||||
|
assert.Equal(t, model.VisibilityPublic, p.Visibility)
|
||||||
|
assert.True(t, p.CanDownload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// PATCH /api/models/:id/visibility
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// TestSetVisibility_OwnerOK 驗證 owner 可改 visibility。
|
||||||
|
func TestSetVisibility_OwnerOK(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility",
|
||||||
|
strings.NewReader(`{"visibility":"public"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||||
|
|
||||||
|
m, err := repo.Get(context.Background(), "m")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.VisibilityPublic, m.Visibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetVisibility_NonOwnerForbidden 驗證非 owner 改 visibility 回 403。
|
||||||
|
func TestSetVisibility_NonOwnerForbidden(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "other", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility",
|
||||||
|
strings.NewReader(`{"visibility":"public"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), ErrCodeForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetVisibility_Invalid 驗證非法 visibility 值回 400。
|
||||||
|
func TestSetVisibility_Invalid(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility",
|
||||||
|
strings.NewReader(`{"visibility":"world"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetVisibility_TenantWithoutOrg 驗證 user 無 org 設 tenant 回 400。
|
||||||
|
func TestSetVisibility_TenantWithoutOrg(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me") // injectStaticUserContext 不設 OrgID
|
||||||
|
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility",
|
||||||
|
strings.NewReader(`{"visibility":"tenant"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code, "無 org 設 tenant 應回 400")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetVisibility_NotReadyConflict 驗證未 finalize 的 model 設公開回 409。
|
||||||
|
func TestSetVisibility_NotReadyConflict(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
// pending model(UploadedAt=nil)。
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||||||
|
ID: "pending", OwnerUserID: "me", Name: "p", StorageKey: "k",
|
||||||
|
FileSize: 1, Source: model.SourceUploaded,
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/models/pending/visibility",
|
||||||
|
strings.NewReader(`{"visibility":"public"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusConflict, w.Code, "未 ready 設公開應回 409")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// shares CRUD(restricted 授權管理)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// TestShares_PutListDelete 驗證 owner 加/列/移除授權完整流程。
|
||||||
|
func TestShares_PutListDelete(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
// PUT 加授權。
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares",
|
||||||
|
strings.NewReader(`{"grantee_user_id":"bob","role":"viewer"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||||
|
|
||||||
|
// GET 列授權。
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/m/shares", nil))
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "bob")
|
||||||
|
|
||||||
|
// DELETE 移除授權。
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/models/m/shares/bob", nil))
|
||||||
|
require.Equal(t, http.StatusNoContent, w.Code)
|
||||||
|
|
||||||
|
// 再列應為空。
|
||||||
|
shares, err := repo.ListShares(context.Background(), "m")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, shares)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShares_NonOwnerForbidden 驗證非 owner 不能管理授權。
|
||||||
|
func TestShares_NonOwnerForbidden(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "other", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares",
|
||||||
|
strings.NewReader(`{"grantee_user_id":"bob"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShares_CannotShareToSelf 驗證不能分享給自己。
|
||||||
|
func TestShares_CannotShareToSelf(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares",
|
||||||
|
strings.NewReader(`{"grantee_user_id":"me"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShares_InvalidRole 驗證非法 role 回 400。
|
||||||
|
func TestShares_InvalidRole(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares",
|
||||||
|
strings.NewReader(`{"grantee_user_id":"bob","role":"admin"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShares_DeleteNonExistent 驗證移除不存在的授權回 404。
|
||||||
|
func TestShares_DeleteNonExistent(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/models/m/shares/ghost", nil))
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// canAccessModel 單元測試(single source of truth)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// TestCanAccessModel_Levels 直接驗 canAccessModel 各級判斷。
|
||||||
|
func TestCanAccessModel_Levels(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
noShare := func(context.Context, string, string) (*model.ModelShare, error) {
|
||||||
|
return nil, model.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
mPrivate := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityPrivate}
|
||||||
|
mPublic := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityPublic}
|
||||||
|
|
||||||
|
// owner
|
||||||
|
assert.Equal(t, model.AccessOwner,
|
||||||
|
canAccessModelForTest(ctx, "owner", mPrivate, noShare))
|
||||||
|
// 無關 user + private → none
|
||||||
|
assert.Equal(t, model.AccessNone,
|
||||||
|
canAccessModelForTest(ctx, "stranger", mPrivate, noShare))
|
||||||
|
// public → viewer
|
||||||
|
assert.Equal(t, model.AccessViewer,
|
||||||
|
canAccessModelForTest(ctx, "stranger", mPublic, noShare))
|
||||||
|
// share viewer
|
||||||
|
shareViewer := func(context.Context, string, string) (*model.ModelShare, error) {
|
||||||
|
return &model.ModelShare{Role: "viewer"}, nil
|
||||||
|
}
|
||||||
|
assert.Equal(t, model.AccessViewer,
|
||||||
|
canAccessModelForTest(ctx, "grantee", mPrivate, shareViewer))
|
||||||
|
// share editor
|
||||||
|
shareEditor := func(context.Context, string, string) (*model.ModelShare, error) {
|
||||||
|
return &model.ModelShare{Role: "editor"}, nil
|
||||||
|
}
|
||||||
|
assert.Equal(t, model.AccessEditor,
|
||||||
|
canAccessModelForTest(ctx, "grantee", mPrivate, shareEditor))
|
||||||
|
// tenant 但 user 無 org(OIDC 現況)→ none(安全預設,stub)
|
||||||
|
mTenant := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityTenant}
|
||||||
|
assert.Equal(t, model.AccessNone,
|
||||||
|
canAccessModelForTest(ctx, "stranger", mTenant, noShare),
|
||||||
|
"tenant + 無 org → none(tenant stub)")
|
||||||
|
}
|
||||||
238
visionA-backend/internal/db/migrate_0006_db_test.go
Normal file
238
visionA-backend/internal/db/migrate_0006_db_test.go
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
//go:build dbtest
|
||||||
|
|
||||||
|
// Migration 0006(模型共享:visibility 欄 + model_shares 表 + index)的真 DB 整合測試。
|
||||||
|
//
|
||||||
|
// build tag `dbtest`:需要 Docker daemon / testcontainers。預設 `go test ./...` 不編譯本檔。
|
||||||
|
// 執行:
|
||||||
|
//
|
||||||
|
// go test -tags=dbtest ./internal/db/...
|
||||||
|
// # 無本機 Docker 時,在 130 補跑:
|
||||||
|
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||||||
|
// go test -tags=dbtest ./internal/db/...
|
||||||
|
//
|
||||||
|
// 對齊 migrations/0006_model_sharing.up.sql / .down.sql 與 feature-model-sharing-tdd.md §3:
|
||||||
|
// 1. apply:models 有 visibility 欄(NOT NULL DEFAULT 'private')、model_shares 表存在、
|
||||||
|
// idx_model_shares_grantee / idx_models_public_active 存在、CHECK constraint 生效。
|
||||||
|
// 2. 既有相容:apply 前既有 model → apply 後 visibility='private'(零行為改變)。
|
||||||
|
// 3. model_shares FK / PK / role CHECK 生效。
|
||||||
|
// 4. rollback 對稱:down 後 visibility 欄與 model_shares 表消失。
|
||||||
|
// 5. re-apply 冪等:up→down→up 不報錯、結果一致。
|
||||||
|
package db_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"visiona-backend/internal/db"
|
||||||
|
"visiona-backend/internal/db/testsupport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// insertRawModel 直接寫入一筆 models(不經 repository),可控制 visibility(傳空用 DB DEFAULT)。
|
||||||
|
// 回傳 model id。
|
||||||
|
func insertRawModel(t *testing.T, tdb *testsupport.TestDB, ownerID, visibility string) string {
|
||||||
|
t.Helper()
|
||||||
|
id := uuid.NewString()
|
||||||
|
ctx := context.Background()
|
||||||
|
if visibility == "" {
|
||||||
|
// 不指定 visibility 欄,走 DB DEFAULT(驗既有相容)。
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source)
|
||||||
|
VALUES ($1, $2, 'raw-model', 'k', 1024, 'uploaded')`,
|
||||||
|
id, ownerID)
|
||||||
|
require.NoError(t, err, "insert raw model (default visibility)")
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source, visibility)
|
||||||
|
VALUES ($1, $2, 'raw-model', 'k', 1024, 'uploaded', $3)`,
|
||||||
|
id, ownerID, visibility)
|
||||||
|
require.NoError(t, err, "insert raw model")
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrate0006_Apply 驗證 up 後 schema 到位(§1)。
|
||||||
|
func TestMigrate0006_Apply(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t) // 已 up 到最新(含 0006)
|
||||||
|
|
||||||
|
assert.True(t, colExists(t, tdb, "models", "visibility"), "models 應有 visibility 欄")
|
||||||
|
assert.True(t, tableExists(t, tdb, "model_shares"), "model_shares 表應存在")
|
||||||
|
|
||||||
|
for _, idx := range []string{"idx_model_shares_grantee", "idx_models_public_active"} {
|
||||||
|
assert.True(t, indexExists(t, tdb, idx), "index %s 應存在", idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibility 應 NOT NULL DEFAULT 'private'。
|
||||||
|
ctx := context.Background()
|
||||||
|
var isNullable, colDefault string
|
||||||
|
err := tdb.Pool.QueryRow(ctx,
|
||||||
|
`SELECT is_nullable, COALESCE(column_default, '') FROM information_schema.columns
|
||||||
|
WHERE table_name = 'models' AND column_name = 'visibility'`).Scan(&isNullable, &colDefault)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "NO", isNullable, "visibility 應 NOT NULL")
|
||||||
|
assert.Contains(t, colDefault, "private", "visibility 預設應為 'private'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrate0006_ExistingModelDefaultsPrivate 驗證既有 model 遷移後 visibility='private'(§2,關鍵相容性)。
|
||||||
|
func TestMigrate0006_ExistingModelDefaultsPrivate(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer mg.Close()
|
||||||
|
|
||||||
|
// 回退 0006 → models 無 visibility 欄。
|
||||||
|
require.NoError(t, mg.Down(), "down 一步回到 0005")
|
||||||
|
require.False(t, colExists(t, tdb, "models", "visibility"), "down 後不應有 visibility 欄")
|
||||||
|
|
||||||
|
owner := tdb.InsertUser(t, "", "")
|
||||||
|
// 在無 visibility 欄的狀態下塞既有 model。
|
||||||
|
existingID := uuid.NewString()
|
||||||
|
_, err = tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source)
|
||||||
|
VALUES ($1, $2, 'legacy', 'k', 1024, 'uploaded')`,
|
||||||
|
existingID, owner)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 重新 up 0006。
|
||||||
|
require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "re-up 0006")
|
||||||
|
|
||||||
|
var vis string
|
||||||
|
err = tdb.Pool.QueryRow(ctx, `SELECT visibility FROM models WHERE id = $1`, existingID).Scan(&vis)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "private", vis, "既有 model 遷移後 visibility 應為 private(零行為改變)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrate0006_VisibilityCheckConstraint 驗證非法 visibility 被 CHECK 擋下(§1)。
|
||||||
|
func TestMigrate0006_VisibilityCheckConstraint(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
owner := tdb.InsertUser(t, "", "")
|
||||||
|
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source, visibility)
|
||||||
|
VALUES ($1, $2, 'bad', 'k', 1024, 'uploaded', 'world')`,
|
||||||
|
uuid.NewString(), owner)
|
||||||
|
assert.Error(t, err, "非法 visibility 'world' 應被 CHECK constraint 擋下")
|
||||||
|
|
||||||
|
// 合法值可寫入。
|
||||||
|
for _, v := range []string{"private", "tenant", "public"} {
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source, visibility)
|
||||||
|
VALUES ($1, $2, 'ok', 'k', 1024, 'uploaded', $3)`,
|
||||||
|
uuid.NewString(), owner, v)
|
||||||
|
assert.NoError(t, err, "合法 visibility %q 應可寫入", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrate0006_ModelSharesConstraints 驗證 model_shares 的 PK / FK / role CHECK(§3)。
|
||||||
|
func TestMigrate0006_ModelSharesConstraints(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
owner := tdb.InsertUser(t, "", "")
|
||||||
|
grantee := tdb.InsertUser(t, "", "")
|
||||||
|
modelID := insertRawModel(t, tdb, owner, "private")
|
||||||
|
|
||||||
|
// 合法 share。
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||||||
|
VALUES ($1, $2, 'viewer', $3)`, modelID, grantee, owner)
|
||||||
|
require.NoError(t, err, "合法 share 應可寫入")
|
||||||
|
|
||||||
|
// PK 重複(同 model + 同 grantee)→ 衝突。
|
||||||
|
_, err = tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||||||
|
VALUES ($1, $2, 'editor', $3)`, modelID, grantee, owner)
|
||||||
|
assert.Error(t, err, "重複 (model_id, grantee_user_id) 應違反 PK")
|
||||||
|
|
||||||
|
// role CHECK:非法 role。
|
||||||
|
grantee2 := tdb.InsertUser(t, "", "")
|
||||||
|
_, err = tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||||||
|
VALUES ($1, $2, 'admin', $3)`, modelID, grantee2, owner)
|
||||||
|
assert.Error(t, err, "非法 role 'admin' 應被 CHECK 擋下")
|
||||||
|
|
||||||
|
// FK:不存在的 model_id。
|
||||||
|
_, err = tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||||||
|
VALUES ($1, $2, 'viewer', $3)`, uuid.NewString(), grantee2, owner)
|
||||||
|
assert.Error(t, err, "不存在的 model_id 應違反 FK")
|
||||||
|
|
||||||
|
// FK:不存在的 grantee_user_id。
|
||||||
|
_, err = tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||||||
|
VALUES ($1, $2, 'viewer', $3)`, modelID, uuid.NewString(), owner)
|
||||||
|
assert.Error(t, err, "不存在的 grantee_user_id 應違反 FK")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrate0006_ModelSharesCascade 驗證 model 硬刪時連帶清 share(ON DELETE CASCADE)。
|
||||||
|
func TestMigrate0006_ModelSharesCascade(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
owner := tdb.InsertUser(t, "", "")
|
||||||
|
grantee := tdb.InsertUser(t, "", "")
|
||||||
|
modelID := insertRawModel(t, tdb, owner, "private")
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||||||
|
VALUES ($1, $2, 'viewer', $3)`, modelID, grantee, owner)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 硬刪 model(非軟刪)→ share 應連帶消失。
|
||||||
|
_, err = tdb.Pool.Exec(ctx, `DELETE FROM models WHERE id = $1`, modelID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var n int
|
||||||
|
err = tdb.Pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM model_shares WHERE model_id = $1`, modelID).Scan(&n)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, n, "model 硬刪後 model_shares 應連帶清空(CASCADE)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrate0006_RollbackSymmetry 驗證 down 對稱:visibility 欄與 model_shares 表消失(§4)。
|
||||||
|
func TestMigrate0006_RollbackSymmetry(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
|
||||||
|
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer mg.Close()
|
||||||
|
|
||||||
|
require.True(t, colExists(t, tdb, "models", "visibility"), "down 前 visibility 欄應存在")
|
||||||
|
require.True(t, tableExists(t, tdb, "model_shares"), "down 前 model_shares 表應存在")
|
||||||
|
|
||||||
|
require.NoError(t, mg.Down(), "down 一步(回退 0006)")
|
||||||
|
|
||||||
|
assert.False(t, colExists(t, tdb, "models", "visibility"), "down 後 visibility 欄應消失")
|
||||||
|
assert.False(t, tableExists(t, tdb, "model_shares"), "down 後 model_shares 表應消失")
|
||||||
|
for _, idx := range []string{"idx_model_shares_grantee", "idx_models_public_active"} {
|
||||||
|
assert.False(t, indexExists(t, tdb, idx), "down 後 index %s 應消失", idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrate0006_ReApplyIdempotent 驗證 up→down→up 不報錯、結果一致(§5)。
|
||||||
|
func TestMigrate0006_ReApplyIdempotent(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
|
||||||
|
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer mg.Close()
|
||||||
|
|
||||||
|
topVer, dirty, err := mg.Version()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, dirty)
|
||||||
|
|
||||||
|
require.NoError(t, mg.Down(), "down 一步")
|
||||||
|
require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "重新 up")
|
||||||
|
|
||||||
|
ver, dirty, err := mg.Version()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, dirty, "up→down→up 後不應 dirty")
|
||||||
|
assert.Equal(t, topVer, ver, "up→down→up 後版本應回到最新")
|
||||||
|
assert.True(t, colExists(t, tdb, "models", "visibility"), "重新 up 後 visibility 欄應再次存在")
|
||||||
|
assert.True(t, tableExists(t, tdb, "model_shares"), "重新 up 後 model_shares 表應再次存在")
|
||||||
|
}
|
||||||
@ -119,6 +119,16 @@ type Repository interface {
|
|||||||
// 實作應更新 UpdatedAt;若為新建則同時設定 CreatedAt。
|
// 實作應更新 UpdatedAt;若為新建則同時設定 CreatedAt。
|
||||||
Save(ctx context.Context, d *Device) error
|
Save(ctx context.Context, d *Device) error
|
||||||
|
|
||||||
|
// SetRegistered 設定 / 清除註冊時間(註冊軸單欄翻轉,feature-device-mgmt-tdd §3.3)。
|
||||||
|
//
|
||||||
|
// - at != nil → 註冊(registered_at = *at)。
|
||||||
|
// - at == nil → 取消註冊(registered_at = NULL),保留列(絕不軟刪 / 撤 token)。
|
||||||
|
//
|
||||||
|
// 只作用於「未刪除、非 representative」的 device(縱深第三層,配合 handler 的 owner /
|
||||||
|
// representative / already-registered 檢查);不符則回 ErrNotFound。register 端的
|
||||||
|
// already-registered 判斷由 handler 先擋(回 409),本方法不重複判。
|
||||||
|
SetRegistered(ctx context.Context, id string, at *time.Time) error
|
||||||
|
|
||||||
// Delete 標記為軟刪除(設定 DeletedAt)。
|
// Delete 標記為軟刪除(設定 DeletedAt)。
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
@ -231,6 +241,30 @@ func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRegistered 設定 / 清除某 device 的 registered_at(單欄翻轉)。
|
||||||
|
//
|
||||||
|
// 語意對齊 PostgresRepository.SetRegistered:只作用於未刪除、非 representative 的 device,
|
||||||
|
// 不符(不存在 / 已軟刪 / representative)回 ErrNotFound(縱深第三層)。一律更新 UpdatedAt。
|
||||||
|
// at==nil 清成未註冊(保留列),at!=nil 設為註冊時間。
|
||||||
|
func (r *InMemoryRepository) SetRegistered(ctx context.Context, id string, at *time.Time) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
d, ok := r.devices[id]
|
||||||
|
if !ok || d.DeletedAt != nil || d.IsRepresentative {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if at != nil {
|
||||||
|
t := at.UTC()
|
||||||
|
d.RegisteredAt = &t
|
||||||
|
} else {
|
||||||
|
d.RegisteredAt = nil
|
||||||
|
}
|
||||||
|
d.UpdatedAt = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetRepresentativeByAgentTx 取得某 agent 的 representative device(is_representative=true、
|
// GetRepresentativeByAgentTx 取得某 agent 的 representative device(is_representative=true、
|
||||||
// 未刪除);不存在回 ErrNotFound。in-memory 忽略 q(無交易需求)。
|
// 未刪除);不存在回 ErrNotFound。in-memory 忽略 q(無交易需求)。
|
||||||
//
|
//
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package device
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@ -174,3 +175,73 @@ func TestInMemoryRepository_GetRepresentativeByAgent(t *testing.T) {
|
|||||||
_, err = r.GetRepresentativeByAgentTx(ctx, nil, "other-agent")
|
_, err = r.GetRepresentativeByAgentTx(ctx, nil, "other-agent")
|
||||||
assert.ErrorIs(t, err, ErrNotFound)
|
assert.ErrorIs(t, err, ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SetRegistered(註冊軸單欄翻轉,feature-device-mgmt-tdd §3.3)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// SetRegistered set → registered_at 有值;set nil → 清空。
|
||||||
|
func TestInMemoryRepository_SetRegistered_SetAndClear(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{ID: "d1", OwnerUserID: "u", SerialNumber: "S-1"}))
|
||||||
|
|
||||||
|
// 初始未註冊。
|
||||||
|
got, err := r.Get(ctx, "d1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, got.RegisteredAt)
|
||||||
|
|
||||||
|
// set → 已註冊。
|
||||||
|
now := time.Now().UTC()
|
||||||
|
require.NoError(t, r.SetRegistered(ctx, "d1", &now))
|
||||||
|
got, err = r.Get(ctx, "d1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got.RegisteredAt)
|
||||||
|
assert.True(t, now.Equal(*got.RegisteredAt))
|
||||||
|
|
||||||
|
// set nil → 退回未註冊,列仍在。
|
||||||
|
require.NoError(t, r.SetRegistered(ctx, "d1", nil))
|
||||||
|
got, err = r.Get(ctx, "d1")
|
||||||
|
require.NoError(t, err, "unregister 不刪列")
|
||||||
|
assert.Nil(t, got.RegisteredAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 冪等:對已 NULL 的列再 set nil → 成功(no-op)。
|
||||||
|
func TestInMemoryRepository_SetRegistered_ClearIdempotent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{ID: "d1", OwnerUserID: "u", SerialNumber: "S-1"}))
|
||||||
|
|
||||||
|
require.NoError(t, r.SetRegistered(ctx, "d1", nil), "未註冊清 nil 應冪等成功")
|
||||||
|
got, _ := r.Get(ctx, "d1")
|
||||||
|
assert.Nil(t, got.RegisteredAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 對 representative device → ErrNotFound(縱深第三層)。
|
||||||
|
func TestInMemoryRepository_SetRegistered_RejectsRepresentative(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{ID: "rep", OwnerUserID: "u", AgentID: "ag", IsRepresentative: true}))
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
assert.ErrorIs(t, r.SetRegistered(ctx, "rep", &now), ErrNotFound,
|
||||||
|
"representative 不可註冊")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 對已軟刪 device → ErrNotFound。
|
||||||
|
func TestInMemoryRepository_SetRegistered_RejectsDeleted(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{ID: "d1", OwnerUserID: "u", SerialNumber: "S-1"}))
|
||||||
|
require.NoError(t, r.Delete(ctx, "d1"))
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
assert.ErrorIs(t, r.SetRegistered(ctx, "d1", &now), ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 對不存在 device → ErrNotFound。
|
||||||
|
func TestInMemoryRepository_SetRegistered_NotFound(t *testing.T) {
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
assert.ErrorIs(t, r.SetRegistered(context.Background(), "ghost", &now), ErrNotFound)
|
||||||
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@ -292,6 +293,37 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRegistered 設定 / 清除 registered_at(註冊軸單欄 UPDATE,feature-device-mgmt-tdd §3.3)。
|
||||||
|
//
|
||||||
|
// 精準單欄 UPDATE(不走 Save 的全欄 upsert),避免「Get→改欄→Save 回去」的讀寫競態面:
|
||||||
|
//
|
||||||
|
// UPDATE devices SET registered_at = $2, updated_at = now()
|
||||||
|
// WHERE id = $1 AND deleted_at IS NULL AND is_representative = false
|
||||||
|
//
|
||||||
|
// WHERE 的 deleted_at IS NULL + is_representative = false 是縱深第三層(配合 handler 的
|
||||||
|
// owner / representative / already-registered 檢查):對不存在 / 已軟刪 / representative 的
|
||||||
|
// 列 RowsAffected()==0 → 回 ErrNotFound。
|
||||||
|
//
|
||||||
|
// - register:at != nil(handler 已先擋 already-registered,這裡不重複判)。
|
||||||
|
// - unregister:at == nil,清成 NULL;對已 NULL 的列 UPDATE 到相同值 RowsAffected 仍為 1
|
||||||
|
// (WHERE 命中),語意上「取消一個未註冊的 = 已達成目標」(冪等,TDD §4.1)。
|
||||||
|
//
|
||||||
|
// 絕不軟刪、不呼叫 DeviceUnpairer、不碰 token(TDD §1.2 紅線)。
|
||||||
|
func (r *PostgresRepository) SetRegistered(ctx context.Context, id string, at *time.Time) error {
|
||||||
|
const sql = `UPDATE devices
|
||||||
|
SET registered_at = $2, updated_at = now()
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL AND is_representative = false`
|
||||||
|
|
||||||
|
tag, err := r.pool.Exec(ctx, sql, id, at)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("device: pg SetRegistered: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetRepresentativeByAgentTx 取得某 agent 的 representative device(is_representative=true、
|
// GetRepresentativeByAgentTx 取得某 agent 的 representative device(is_representative=true、
|
||||||
// 未刪除);不存在回 ErrNotFound(在傳入 Querier / tx 上執行)。
|
// 未刪除);不存在回 ErrNotFound(在傳入 Querier / tx 上執行)。
|
||||||
//
|
//
|
||||||
|
|||||||
@ -743,3 +743,108 @@ func TestPG_ContextCancel(t *testing.T) {
|
|||||||
_, err = r.List(ctx, owner)
|
_, err = r.List(ctx, owner)
|
||||||
assert.Error(t, err, "已取消 ctx 的 List 應回 error")
|
assert.Error(t, err, "已取消 ctx 的 List 應回 error")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SetRegistered(註冊軸單欄 UPDATE,feature-device-mgmt-tdd §3.3 / WS-BE)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// pgInsertAgent 建一筆 agent(滿足 devices.agent_id FK),回傳 agentID。
|
||||||
|
func pgInsertAgent(t *testing.T, tdb *testsupport.TestDB, owner string) string {
|
||||||
|
t.Helper()
|
||||||
|
agentID := uuid.NewString()
|
||||||
|
_, err := tdb.Pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`,
|
||||||
|
agentID, owner)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return agentID
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered set → 已註冊;set nil → 退回未註冊(列保留)。
|
||||||
|
func TestPG_SetRegistered_SetAndClear(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r, tdb, owner := newPGRepo(t)
|
||||||
|
agentID := pgInsertAgent(t, tdb, owner)
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{
|
||||||
|
ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0x11111111", AgentID: agentID,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 初始未註冊。
|
||||||
|
got, err := r.Get(ctx, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, got.RegisteredAt)
|
||||||
|
|
||||||
|
// set → 已註冊。
|
||||||
|
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||||
|
require.NoError(t, r.SetRegistered(ctx, id, &now))
|
||||||
|
got, err = r.Get(ctx, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got.RegisteredAt, "register 後 registered_at 非 null")
|
||||||
|
assert.True(t, now.Equal(*got.RegisteredAt))
|
||||||
|
|
||||||
|
// set nil → 退回未註冊、列仍在(絕不軟刪)。
|
||||||
|
require.NoError(t, r.SetRegistered(ctx, id, nil))
|
||||||
|
got, err = r.Get(ctx, id)
|
||||||
|
require.NoError(t, err, "unregister 不軟刪,Get 應仍取得")
|
||||||
|
assert.Nil(t, got.RegisteredAt)
|
||||||
|
assert.Equal(t, 1, tdb.CountRows(t, "devices"), "unregister 不刪列,devices 仍 1 筆")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 冪等:對已 NULL 的列 set nil → RowsAffected 命中、成功 no-op。
|
||||||
|
func TestPG_SetRegistered_ClearIdempotent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r, tdb, owner := newPGRepo(t)
|
||||||
|
agentID := pgInsertAgent(t, tdb, owner)
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{
|
||||||
|
ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0x22222222", AgentID: agentID,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 未註冊再清 → 成功(WHERE 命中、RowsAffected=1、UPDATE 到相同 NULL)。
|
||||||
|
require.NoError(t, r.SetRegistered(ctx, id, nil), "未註冊清 nil 應冪等成功")
|
||||||
|
got, err := r.Get(ctx, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, got.RegisteredAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 對 representative device → RowsAffected=0 → ErrNotFound(WHERE is_representative=false)。
|
||||||
|
func TestPG_SetRegistered_RejectsRepresentative(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r, tdb, owner := newPGRepo(t)
|
||||||
|
agentID := pgInsertAgent(t, tdb, owner)
|
||||||
|
|
||||||
|
repID := uuid.NewString()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{
|
||||||
|
ID: repID, OwnerUserID: owner, Name: "rep", AgentID: agentID, IsRepresentative: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
assert.ErrorIs(t, r.SetRegistered(ctx, repID, &now), ErrNotFound,
|
||||||
|
"representative device 應被 WHERE is_representative=false 擋成 ErrNotFound")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 對已軟刪 device → RowsAffected=0 → ErrNotFound(WHERE deleted_at IS NULL)。
|
||||||
|
func TestPG_SetRegistered_RejectsDeleted(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r, tdb, owner := newPGRepo(t)
|
||||||
|
agentID := pgInsertAgent(t, tdb, owner)
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{
|
||||||
|
ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0x33333333", AgentID: agentID,
|
||||||
|
}))
|
||||||
|
require.NoError(t, r.Delete(ctx, id)) // 軟刪
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
assert.ErrorIs(t, r.SetRegistered(ctx, id, &now), ErrNotFound,
|
||||||
|
"已軟刪 device 應回 ErrNotFound")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 對不存在 device → ErrNotFound。
|
||||||
|
func TestPG_SetRegistered_NotFound(t *testing.T) {
|
||||||
|
r, _, _ := newPGRepo(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
assert.ErrorIs(t, r.SetRegistered(context.Background(), uuid.NewString(), &now), ErrNotFound)
|
||||||
|
}
|
||||||
|
|||||||
152
visionA-backend/internal/model/inmemory_sharing_test.go
Normal file
152
visionA-backend/internal/model/inmemory_sharing_test.go
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readyModel 建一個 ready(UploadedAt 已設)的 model helper。
|
||||||
|
func readyModel(id, owner, visibility string) *Model {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
return &Model{
|
||||||
|
ID: id, OwnerUserID: owner, Name: "m-" + id,
|
||||||
|
StorageKey: "k/" + id, FileSize: 1024,
|
||||||
|
Source: SourceUploaded, Visibility: visibility, UploadedAt: &now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInMemory_SaveDefaultsVisibility 驗證 Save 未設 visibility 時預設 private。
|
||||||
|
func TestInMemory_SaveDefaultsVisibility(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
require.NoError(t, r.Save(ctx, &Model{ID: "m", OwnerUserID: "u", Name: "n", StorageKey: "k", Source: SourceUploaded}))
|
||||||
|
got, err := r.Get(ctx, "m")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, VisibilityPrivate, got.Visibility, "未設 visibility 應預設 private")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInMemory_ShareCRUD 驗證 share 的 Upsert / Get / List / Delete。
|
||||||
|
func TestInMemory_ShareCRUD(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "m", GranteeUserID: "bob", Role: "viewer", GrantedBy: "owner"}))
|
||||||
|
got, err := r.GetShare(ctx, "m", "bob")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "viewer", got.Role)
|
||||||
|
|
||||||
|
// upsert 同 grantee → 更新 role。
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "m", GranteeUserID: "bob", Role: "editor", GrantedBy: "owner"}))
|
||||||
|
got, err = r.GetShare(ctx, "m", "bob")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "editor", got.Role, "重複 upsert 應更新 role")
|
||||||
|
|
||||||
|
// list
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "m", GranteeUserID: "alice", Role: "viewer", GrantedBy: "owner"}))
|
||||||
|
shares, err := r.ListShares(ctx, "m")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, shares, 2)
|
||||||
|
|
||||||
|
// delete
|
||||||
|
require.NoError(t, r.DeleteShare(ctx, "m", "bob"))
|
||||||
|
_, err = r.GetShare(ctx, "m", "bob")
|
||||||
|
assert.ErrorIs(t, err, ErrNotFound)
|
||||||
|
|
||||||
|
// delete 不存在 → ErrNotFound
|
||||||
|
assert.ErrorIs(t, r.DeleteShare(ctx, "m", "ghost"), ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInMemory_LibraryVisibility 驗證 Library predicate:我的 ∪ public ∪ shared,排除別人 private。
|
||||||
|
func TestInMemory_LibraryVisibility(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
|
||||||
|
require.NoError(t, r.Save(ctx, readyModel("mine", "me", VisibilityPrivate)))
|
||||||
|
require.NoError(t, r.Save(ctx, readyModel("otherPriv", "other", VisibilityPrivate)))
|
||||||
|
require.NoError(t, r.Save(ctx, readyModel("otherPub", "other", VisibilityPublic)))
|
||||||
|
require.NoError(t, r.Save(ctx, readyModel("otherShared", "other", VisibilityPrivate)))
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "otherShared", GranteeUserID: "me", Role: "viewer", GrantedBy: "other"}))
|
||||||
|
|
||||||
|
items, _, err := r.Library(ctx, LibraryQuery{UserID: "me", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ids := map[string]*LibraryItem{}
|
||||||
|
for _, it := range items {
|
||||||
|
ids[it.Model.ID] = it
|
||||||
|
}
|
||||||
|
assert.Contains(t, ids, "mine")
|
||||||
|
assert.Contains(t, ids, "otherPub")
|
||||||
|
assert.Contains(t, ids, "otherShared")
|
||||||
|
assert.NotContains(t, ids, "otherPriv", "別人 private 不應可見")
|
||||||
|
assert.True(t, ids["otherShared"].SharedWithMe)
|
||||||
|
assert.Equal(t, AccessOwner, ids["mine"].MyAccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInMemory_LibraryExcludesNotReady 驗證未 ready 的 model 不進 Library。
|
||||||
|
func TestInMemory_LibraryExcludesNotReady(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
require.NoError(t, r.Save(ctx, &Model{ID: "pending", OwnerUserID: "me", Name: "p", StorageKey: "k", Source: SourceUploaded, Visibility: VisibilityPublic}))
|
||||||
|
|
||||||
|
items, _, err := r.Library(ctx, LibraryQuery{UserID: "me", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInMemory_LibraryTenantStub 驗證 tenant 可見性:有 org 對應才命中(in-memory 用 SetUserOrg 模擬)。
|
||||||
|
func TestInMemory_LibraryTenantStub(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
r.SetUserOrg("owner", "org-1")
|
||||||
|
r.SetUserOrg("teammate", "org-1")
|
||||||
|
r.SetUserOrg("outsider", "org-2")
|
||||||
|
require.NoError(t, r.Save(ctx, readyModel("tenantModel", "owner", VisibilityTenant)))
|
||||||
|
|
||||||
|
// 同 org → 可見
|
||||||
|
items, _, err := r.Library(ctx, LibraryQuery{UserID: "teammate", UserOrgID: "org-1", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
assert.Equal(t, "tenantModel", items[0].Model.ID)
|
||||||
|
|
||||||
|
// 異 org → 不可見
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: "outsider", UserOrgID: "org-2", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, items, "異 org 不應看到 tenant model")
|
||||||
|
|
||||||
|
// 無 org(OIDC 現況)→ 不可見(安全預設)
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: "teammate", UserOrgID: "", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, items, "無 org 不應命中 tenant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInMemory_LibraryPaginationStable 驗證 cursor 分頁不重不漏。
|
||||||
|
func TestInMemory_LibraryPaginationStable(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
require.NoError(t, r.Save(ctx, readyModel(string(rune('a'+i)), "me", VisibilityPrivate)))
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var cursor *Cursor
|
||||||
|
for page := 0; page < 10; page++ {
|
||||||
|
items, hasMore, err := r.Library(ctx, LibraryQuery{
|
||||||
|
UserID: "me", Limit: 2, Sort: "name", Order: "asc", Cursor: cursor,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, it := range items {
|
||||||
|
assert.False(t, seen[it.Model.ID], "分頁重複 %s", it.Model.ID)
|
||||||
|
seen[it.Model.ID] = true
|
||||||
|
}
|
||||||
|
if !hasMore {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
last := items[len(items)-1].Model
|
||||||
|
cursor = &Cursor{ID: last.ID, SortValue: last.Name}
|
||||||
|
}
|
||||||
|
assert.Len(t, seen, 5, "所有 model 應被分頁走過一次")
|
||||||
|
}
|
||||||
@ -8,6 +8,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -41,6 +43,54 @@ const (
|
|||||||
SourcePreset Source = "preset"
|
SourcePreset Source = "preset"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Visibility 常數(廣播式公開對象;對齊 feature-model-sharing-tdd.md §3.1)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// Visibility 是 Model 的「公開對象」廣播維度:一個 model 一個值。
|
||||||
|
// 與 model_shares(點對點分享)正交。
|
||||||
|
type Visibility = string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// VisibilityPrivate 僅擁有者可見(= 現況預設行為;新 model 與既有 model 皆為此值)。
|
||||||
|
VisibilityPrivate Visibility = "private"
|
||||||
|
// VisibilityTenant 同租戶(同 org_id)可見。
|
||||||
|
// 依賴 users.org_id;OIDC 現況不帶 org claim(見 postgres_repository.go List 說明),
|
||||||
|
// 故目前 tenant 命中集合恆為空(安全預設)——schema/predicate 就緒,等 OIDC 補 org claim 即生效。
|
||||||
|
VisibilityTenant Visibility = "tenant"
|
||||||
|
// VisibilityPublic 全平台已登入 user 可見。
|
||||||
|
VisibilityPublic Visibility = "public"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsValidVisibility 回報 v 是否為合法的 visibility 值(handler 驗 PATCH 輸入用)。
|
||||||
|
func IsValidVisibility(v string) bool {
|
||||||
|
switch v {
|
||||||
|
case VisibilityPrivate, VisibilityTenant, VisibilityPublic:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// AccessLevel 常數(可見性判斷的結果;對齊 TDD §6 SEC-2 / api §1 my_access)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// AccessLevel 是「當前 user 對某 model 的有效權限」。
|
||||||
|
// 由 canAccessModel(single source of truth)計算,取最高。
|
||||||
|
type AccessLevel = string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AccessNone 無可見性(不該看到此 model;enumeration 防護一律回 404)。
|
||||||
|
AccessNone AccessLevel = "none"
|
||||||
|
// AccessViewer 可 list / get profile / download(visibility 命中或 share role=viewer)。
|
||||||
|
AccessViewer AccessLevel = "viewer"
|
||||||
|
// AccessEditor 可改 metadata(share role=editor);含 viewer 全部權限。
|
||||||
|
AccessEditor AccessLevel = "editor"
|
||||||
|
// AccessOwner 擁有者,完整權限(可改 visibility / 刪除 / 分享)。
|
||||||
|
AccessOwner AccessLevel = "owner"
|
||||||
|
)
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Model struct(對齊 database.md §2.3)
|
// Model struct(對齊 database.md §2.3)
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@ -79,12 +129,75 @@ type Model struct {
|
|||||||
Source Source `json:"source"`
|
Source Source `json:"source"`
|
||||||
SourceJobID string `json:"sourceJobId,omitempty"`
|
SourceJobID string `json:"sourceJobId,omitempty"`
|
||||||
|
|
||||||
|
// Visibility 是廣播式公開對象(private / tenant / public,對齊 model_sharing 功能)。
|
||||||
|
// 既有 / 新建 model 預設 VisibilityPrivate(DB DEFAULT 'private',零行為改變)。
|
||||||
|
Visibility Visibility `json:"visibility"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
UploadedAt *time.Time `json:"uploadedAt,omitempty"`
|
UploadedAt *time.Time `json:"uploadedAt,omitempty"`
|
||||||
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// ModelShare(點對點分享關聯;對齊 ADR-017 決策 3 B1 / model_shares 表)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// ModelShare 是一筆「model 分享給特定 grantee」的授權紀錄。
|
||||||
|
type ModelShare struct {
|
||||||
|
ModelID string `json:"modelId"`
|
||||||
|
GranteeUserID string `json:"granteeUserId"`
|
||||||
|
Role string `json:"role"` // 'viewer' | 'editor'
|
||||||
|
GrantedBy string `json:"grantedBy"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryItem 是共享庫列表的一列:Model + 該列相對於查詢 user 的存取資訊。
|
||||||
|
//
|
||||||
|
// owner_name / owner_org_id 由 repository 一次 JOIN users 帶出(避免 handler N+1)。
|
||||||
|
// SharedWithMe / MyAccess 由 repository 依查詢 user 身份計算填入。
|
||||||
|
type LibraryItem struct {
|
||||||
|
Model *Model
|
||||||
|
OwnerName string
|
||||||
|
OwnerOrgID string
|
||||||
|
SharedWithMe bool
|
||||||
|
MyAccess AccessLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryQuery 是共享庫列表查詢的參數(對齊 api §1)。
|
||||||
|
//
|
||||||
|
// Viewer 身份(UserID / UserOrgID)決定可見範圍;其餘為 filter / 排序 / cursor 分頁。
|
||||||
|
type LibraryQuery struct {
|
||||||
|
// Viewer 身份(可見性 predicate 的 input)。
|
||||||
|
UserID string
|
||||||
|
UserOrgID string // 空字串 → tenant 維度不命中任何 model(安全預設)
|
||||||
|
|
||||||
|
// filter(皆可選,空值 = 不過濾該維度)。
|
||||||
|
TargetChip string
|
||||||
|
Source Source
|
||||||
|
Visibility Visibility // 僅 'public' / 'tenant' 有意義;'private' 傳入視為忽略
|
||||||
|
Q string // 搜尋 name + description(ILIKE 包含)
|
||||||
|
// Owned:nil = 全部可見;true = 只我的;false = 只別人分享/公開給我的。
|
||||||
|
Owned *bool
|
||||||
|
|
||||||
|
// 排序 + 分頁。
|
||||||
|
Sort string // 'created_at' | 'name' | 'file_size'(handler 已 validate)
|
||||||
|
Order string // 'asc' | 'desc'
|
||||||
|
Limit int // handler 已 clamp 到 1–100
|
||||||
|
Cursor *Cursor // nil = 首頁
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor 是 keyset 分頁游標,記錄上一頁最後一筆的排序值 + id tie-breaker。
|
||||||
|
//
|
||||||
|
// 由 handler 以不透明 base64 編碼給前端(見 api §1.3);repository 只吃解碼後的結構。
|
||||||
|
type Cursor struct {
|
||||||
|
// SortValue 是上一頁最後一筆的排序欄位值,型別依 Sort 而定:
|
||||||
|
// created_at → RFC3339 時間字串;name → 字串;file_size → 十進位整數字串。
|
||||||
|
SortValue string `json:"v"`
|
||||||
|
// ID 是上一頁最後一筆的 model id(tie-breaker,保證穩定分頁)。
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Filter / Repository
|
// Filter / Repository
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@ -103,6 +216,11 @@ type Repository interface {
|
|||||||
// Get 取得單一 Model;不存在或已刪除回 ErrNotFound。
|
// Get 取得單一 Model;不存在或已刪除回 ErrNotFound。
|
||||||
Get(ctx context.Context, id string) (*Model, error)
|
Get(ctx context.Context, id string) (*Model, error)
|
||||||
|
|
||||||
|
// GetWithOwner 取得單一 Model 並一併帶出 owner 的顯示名稱(一次 join users,避免 N+1)。
|
||||||
|
// 供 profile handler 顯示擁有者名(api §2 owner.name)。ownerName 可能為空(owner 未設 name)。
|
||||||
|
// 不存在或已刪除回 ErrNotFound。
|
||||||
|
GetWithOwner(ctx context.Context, id string) (m *Model, ownerName string, err error)
|
||||||
|
|
||||||
// List 依 filter 列出 Model;filter.OwnerUserID 不同於空字串時限定擁有者。
|
// List 依 filter 列出 Model;filter.OwnerUserID 不同於空字串時限定擁有者。
|
||||||
List(ctx context.Context, filter ListFilter) ([]*Model, error)
|
List(ctx context.Context, filter ListFilter) ([]*Model, error)
|
||||||
|
|
||||||
@ -111,6 +229,29 @@ type Repository interface {
|
|||||||
|
|
||||||
// Delete 軟刪除。
|
// Delete 軟刪除。
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
// ── 模型共享(model_sharing 功能新增)─────────────────────────────────
|
||||||
|
|
||||||
|
// Library 依查詢 user 身份列出「可見」的 model(我的 ∪ public ∪ tenant同org ∪ 分享給我),
|
||||||
|
// 支援 filter / 排序 / cursor 分頁。回傳 items(已含 owner_name / my_access / shared_with_me)
|
||||||
|
// 與是否還有下一頁(hasMore)。preset 由 handler 層 union,不在此。
|
||||||
|
//
|
||||||
|
// 只列 uploaded_at IS NOT NULL(ready)的 model;共享庫不列未 finalize 的。
|
||||||
|
Library(ctx context.Context, q LibraryQuery) (items []*LibraryItem, hasMore bool, err error)
|
||||||
|
|
||||||
|
// GetShare 取得 (modelID, granteeUserID) 的分享紀錄;不存在回 ErrNotFound。
|
||||||
|
// 供 canAccessModel 單筆查「這個 model 有沒有分享給我」。
|
||||||
|
GetShare(ctx context.Context, modelID, granteeUserID string) (*ModelShare, error)
|
||||||
|
|
||||||
|
// ListShares 列出某 model 的所有分享紀錄(owner 檢視授權清單用)。
|
||||||
|
ListShares(ctx context.Context, modelID string) ([]*ModelShare, error)
|
||||||
|
|
||||||
|
// UpsertShare 新增 / 更新一筆分享(by PK (model_id, grantee_user_id))。
|
||||||
|
// 重複分享同一 grantee → 更新 role。
|
||||||
|
UpsertShare(ctx context.Context, s *ModelShare) error
|
||||||
|
|
||||||
|
// DeleteShare 移除 (modelID, granteeUserID) 的分享;不存在回 ErrNotFound。
|
||||||
|
DeleteShare(ctx context.Context, modelID, granteeUserID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@ -149,15 +290,41 @@ func (v *SizeValidator) Check(size int64) error {
|
|||||||
type InMemoryRepository struct {
|
type InMemoryRepository struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
models map[string]*Model
|
models map[string]*Model
|
||||||
|
// shares 以 modelID → (granteeUserID → *ModelShare) 兩層 map 存分享關聯。
|
||||||
|
shares map[string]map[string]*ModelShare
|
||||||
|
// orgs 記錄 userID → org_id,供 in-memory Library 判 tenant 可見性(測試注入用)。
|
||||||
|
// production 走 Postgres 實作;in-memory 主要供 unit test,故用簡易注入而非 join users。
|
||||||
|
orgs map[string]string
|
||||||
|
// names 記錄 userID → 顯示名稱,供 in-memory GetWithOwner / Library 帶出 owner name。
|
||||||
|
names map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInMemoryRepository 建立一個空的記憶體 Repository。
|
// NewInMemoryRepository 建立一個空的記憶體 Repository。
|
||||||
func NewInMemoryRepository() *InMemoryRepository {
|
func NewInMemoryRepository() *InMemoryRepository {
|
||||||
return &InMemoryRepository{
|
return &InMemoryRepository{
|
||||||
models: make(map[string]*Model),
|
models: make(map[string]*Model),
|
||||||
|
shares: make(map[string]map[string]*ModelShare),
|
||||||
|
orgs: make(map[string]string),
|
||||||
|
names: make(map[string]string),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetUserOrg 設定某 user 的 org_id(僅 in-memory 測試用,讓 Library 能判 tenant 可見性)。
|
||||||
|
// production 的 Postgres 實作直接 join users.org_id,不需此方法。
|
||||||
|
func (r *InMemoryRepository) SetUserOrg(userID, orgID string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.orgs[userID] = orgID
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserName 設定某 user 的顯示名稱(僅 in-memory 測試用,讓 GetWithOwner / Library 帶出 owner name)。
|
||||||
|
// production 的 Postgres 實作直接 join users.name,不需此方法。
|
||||||
|
func (r *InMemoryRepository) SetUserName(userID, name string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.names[userID] = name
|
||||||
|
}
|
||||||
|
|
||||||
// Get 取得單一 Model。
|
// Get 取得單一 Model。
|
||||||
func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error) {
|
func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
@ -171,6 +338,19 @@ func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error)
|
|||||||
return &cp, nil
|
return &cp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetWithOwner 取單一 Model + owner 顯示名稱(in-memory 從 names map 取,測試以 SetUserName 注入)。
|
||||||
|
func (r *InMemoryRepository) GetWithOwner(ctx context.Context, id string) (*Model, string, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
m, ok := r.models[id]
|
||||||
|
if !ok || m.DeletedAt != nil {
|
||||||
|
return nil, "", ErrNotFound
|
||||||
|
}
|
||||||
|
cp := *m
|
||||||
|
return &cp, r.names[m.OwnerUserID], nil
|
||||||
|
}
|
||||||
|
|
||||||
// List 依條件列出 Model。
|
// List 依條件列出 Model。
|
||||||
func (r *InMemoryRepository) List(ctx context.Context, filter ListFilter) ([]*Model, error) {
|
func (r *InMemoryRepository) List(ctx context.Context, filter ListFilter) ([]*Model, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
@ -211,6 +391,10 @@ func (r *InMemoryRepository) Save(ctx context.Context, m *Model) error {
|
|||||||
} else if cp.CreatedAt.IsZero() {
|
} else if cp.CreatedAt.IsZero() {
|
||||||
cp.CreatedAt = now
|
cp.CreatedAt = now
|
||||||
}
|
}
|
||||||
|
// visibility 預設 private(對齊 DB DEFAULT 'private'):呼叫端未設時不會意外變公開。
|
||||||
|
if cp.Visibility == "" {
|
||||||
|
cp.Visibility = VisibilityPrivate
|
||||||
|
}
|
||||||
cp.UpdatedAt = now
|
cp.UpdatedAt = now
|
||||||
r.models[m.ID] = &cp
|
r.models[m.ID] = &cp
|
||||||
return nil
|
return nil
|
||||||
@ -231,5 +415,222 @@ func (r *InMemoryRepository) Delete(ctx context.Context, id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// InMemoryRepository — 模型共享方法
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Library 依查詢 user 身份列出可見 model(in-memory 實作,供 unit test)。
|
||||||
|
//
|
||||||
|
// 可見性 predicate 對齊 TDD §4.1(我的 ∪ public ∪ tenant同org ∪ 分享給我)。
|
||||||
|
// 排序 + cursor 分頁在記憶體內以全掃 + sort + 切片實作(in-memory 資料量小、不追求效能)。
|
||||||
|
func (r *InMemoryRepository) Library(ctx context.Context, q LibraryQuery) ([]*LibraryItem, bool, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
var matched []*LibraryItem
|
||||||
|
for _, m := range r.models {
|
||||||
|
if m.DeletedAt != nil || m.UploadedAt == nil {
|
||||||
|
continue // 共享庫只列未刪除且 ready 的 model
|
||||||
|
}
|
||||||
|
access := r.accessLevelLocked(q.UserID, q.UserOrgID, m)
|
||||||
|
if access == AccessNone {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// filter:owned 維度。
|
||||||
|
isMine := m.OwnerUserID == q.UserID
|
||||||
|
if q.Owned != nil {
|
||||||
|
if *q.Owned && !isMine {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !*q.Owned && isMine {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.TargetChip != "" && m.TargetChip != q.TargetChip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.Source != "" && m.Source != q.Source {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// visibility filter:僅 public / tenant 有意義(private 不在共享庫語意內)。
|
||||||
|
if q.Visibility == VisibilityPublic || q.Visibility == VisibilityTenant {
|
||||||
|
if m.Visibility != q.Visibility {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.Q != "" {
|
||||||
|
needle := strings.ToLower(q.Q)
|
||||||
|
if !strings.Contains(strings.ToLower(m.Name), needle) &&
|
||||||
|
!strings.Contains(strings.ToLower(m.Description), needle) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, shared := r.shareForLocked(m.ID, q.UserID)
|
||||||
|
cp := *m
|
||||||
|
matched = append(matched, &LibraryItem{
|
||||||
|
Model: &cp,
|
||||||
|
OwnerName: r.names[m.OwnerUserID], // in-memory 從 names map 取(測試以 SetUserName 注入)
|
||||||
|
OwnerOrgID: r.orgs[m.OwnerUserID],
|
||||||
|
SharedWithMe: shared,
|
||||||
|
MyAccess: access,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sortLibraryItems(matched, q.Sort, q.Order)
|
||||||
|
|
||||||
|
// cursor:找到游標對應 item 後的位置,取其後 limit+1 判 hasMore。
|
||||||
|
start := 0
|
||||||
|
if q.Cursor != nil {
|
||||||
|
for i, it := range matched {
|
||||||
|
if it.Model.ID == q.Cursor.ID {
|
||||||
|
start = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
limit := q.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
end := start + limit
|
||||||
|
hasMore := false
|
||||||
|
if end < len(matched) {
|
||||||
|
hasMore = true
|
||||||
|
}
|
||||||
|
if start > len(matched) {
|
||||||
|
start = len(matched)
|
||||||
|
}
|
||||||
|
if end > len(matched) {
|
||||||
|
end = len(matched)
|
||||||
|
}
|
||||||
|
return matched[start:end], hasMore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// accessLevelLocked 計算 user 對 model 的 AccessLevel(呼叫端須持 r.mu)。
|
||||||
|
// 對齊 canAccessModel 的 in-memory 版;順序:owner > share.role > visibility(viewer) > none。
|
||||||
|
func (r *InMemoryRepository) accessLevelLocked(userID, userOrgID string, m *Model) AccessLevel {
|
||||||
|
if m.OwnerUserID == userID {
|
||||||
|
return AccessOwner
|
||||||
|
}
|
||||||
|
if s, ok := r.shareForLocked(m.ID, userID); ok {
|
||||||
|
if s.Role == "editor" {
|
||||||
|
return AccessEditor
|
||||||
|
}
|
||||||
|
return AccessViewer
|
||||||
|
}
|
||||||
|
if m.Visibility == VisibilityPublic {
|
||||||
|
return AccessViewer
|
||||||
|
}
|
||||||
|
if m.Visibility == VisibilityTenant && userOrgID != "" && r.orgs[m.OwnerUserID] == userOrgID {
|
||||||
|
return AccessViewer
|
||||||
|
}
|
||||||
|
return AccessNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// shareForLocked 回傳 (modelID, granteeUserID) 的 share(呼叫端須持 r.mu)。
|
||||||
|
func (r *InMemoryRepository) shareForLocked(modelID, granteeUserID string) (*ModelShare, bool) {
|
||||||
|
byGrantee, ok := r.shares[modelID]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
s, ok := byGrantee[granteeUserID]
|
||||||
|
return s, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetShare 取得單筆 share;不存在回 ErrNotFound。
|
||||||
|
func (r *InMemoryRepository) GetShare(ctx context.Context, modelID, granteeUserID string) (*ModelShare, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
s, ok := r.shareForLocked(modelID, granteeUserID)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
cp := *s
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListShares 列出某 model 的所有 share。
|
||||||
|
func (r *InMemoryRepository) ListShares(ctx context.Context, modelID string) ([]*ModelShare, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
out := make([]*ModelShare, 0)
|
||||||
|
for _, s := range r.shares[modelID] {
|
||||||
|
cp := *s
|
||||||
|
out = append(out, &cp)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertShare 新增 / 更新一筆 share(by PK)。
|
||||||
|
func (r *InMemoryRepository) UpsertShare(ctx context.Context, s *ModelShare) error {
|
||||||
|
if s == nil || s.ModelID == "" || s.GranteeUserID == "" {
|
||||||
|
return errors.New("model: UpsertShare requires modelID and granteeUserID")
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.shares[s.ModelID] == nil {
|
||||||
|
r.shares[s.ModelID] = make(map[string]*ModelShare)
|
||||||
|
}
|
||||||
|
cp := *s
|
||||||
|
if cp.CreatedAt.IsZero() {
|
||||||
|
cp.CreatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if cp.Role == "" {
|
||||||
|
cp.Role = "viewer"
|
||||||
|
}
|
||||||
|
r.shares[s.ModelID][s.GranteeUserID] = &cp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteShare 移除一筆 share;不存在回 ErrNotFound。
|
||||||
|
func (r *InMemoryRepository) DeleteShare(ctx context.Context, modelID, granteeUserID string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
byGrantee, ok := r.shares[modelID]
|
||||||
|
if !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if _, ok := byGrantee[granteeUserID]; !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
delete(byGrantee, granteeUserID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortLibraryItems 依 sort/order 就地排序 items;tie-breaker 一律用 model id 保證穩定。
|
||||||
|
func sortLibraryItems(items []*LibraryItem, sortField, order string) {
|
||||||
|
desc := order != "asc" // 預設 desc
|
||||||
|
less := func(i, j int) bool {
|
||||||
|
a, b := items[i].Model, items[j].Model
|
||||||
|
var cmp int
|
||||||
|
switch sortField {
|
||||||
|
case "name":
|
||||||
|
cmp = strings.Compare(a.Name, b.Name)
|
||||||
|
case "file_size":
|
||||||
|
switch {
|
||||||
|
case a.FileSize < b.FileSize:
|
||||||
|
cmp = -1
|
||||||
|
case a.FileSize > b.FileSize:
|
||||||
|
cmp = 1
|
||||||
|
}
|
||||||
|
default: // created_at
|
||||||
|
switch {
|
||||||
|
case a.CreatedAt.Before(b.CreatedAt):
|
||||||
|
cmp = -1
|
||||||
|
case a.CreatedAt.After(b.CreatedAt):
|
||||||
|
cmp = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmp == 0 {
|
||||||
|
cmp = strings.Compare(a.ID, b.ID) // tie-breaker
|
||||||
|
}
|
||||||
|
if desc {
|
||||||
|
return cmp > 0
|
||||||
|
}
|
||||||
|
return cmp < 0
|
||||||
|
}
|
||||||
|
sort.SliceStable(items, less)
|
||||||
|
}
|
||||||
|
|
||||||
// 編譯時檢查:確保 InMemoryRepository 實作 Repository。
|
// 編譯時檢查:確保 InMemoryRepository 實作 Repository。
|
||||||
var _ Repository = (*InMemoryRepository)(nil)
|
var _ Repository = (*InMemoryRepository)(nil)
|
||||||
|
|||||||
@ -46,7 +46,7 @@ var _ Repository = (*PostgresRepository)(nil)
|
|||||||
// modelColumns 是 SELECT / RETURNING 共用的欄位清單(順序必須與 scanModel 對齊)。
|
// modelColumns 是 SELECT / RETURNING 共用的欄位清單(順序必須與 scanModel 對齊)。
|
||||||
const modelColumns = `id, owner_user_id, name, description, storage_key, file_size,
|
const modelColumns = `id, owner_user_id, name, description, storage_key, file_size,
|
||||||
file_checksum, faa_object_key, target_chip, input_shape, classes, framework,
|
file_checksum, faa_object_key, target_chip, input_shape, classes, framework,
|
||||||
source, source_job_id, created_at, updated_at, uploaded_at, deleted_at`
|
source, source_job_id, visibility, created_at, updated_at, uploaded_at, deleted_at`
|
||||||
|
|
||||||
// Get 取得單一 Model;不存在或已軟刪除回 ErrNotFound。
|
// Get 取得單一 Model;不存在或已軟刪除回 ErrNotFound。
|
||||||
func (r *PostgresRepository) Get(ctx context.Context, id string) (*Model, error) {
|
func (r *PostgresRepository) Get(ctx context.Context, id string) (*Model, error) {
|
||||||
@ -139,15 +139,22 @@ func (r *PostgresRepository) Save(ctx context.Context, m *Model) error {
|
|||||||
|
|
||||||
// nullable 欄位以指標 / 空值交給 pgx 處理;空字串對 nullable TEXT 欄位寫入空字串(非 NULL),
|
// nullable 欄位以指標 / 空值交給 pgx 處理;空字串對 nullable TEXT 欄位寫入空字串(非 NULL),
|
||||||
// 對齊 in-memory「zero value 即空字串」語意(faa_object_key 等查詢端以 != '' 判斷)。
|
// 對齊 in-memory「zero value 即空字串」語意(faa_object_key 等查詢端以 != '' 判斷)。
|
||||||
|
// visibility:空字串 → NULL 交給 COALESCE 落 'private'(對齊 DB DEFAULT + in-memory Save)。
|
||||||
|
// 已設值(PATCH visibility / 呼叫端指定)則原樣寫入;CHECK constraint 擋非法值。
|
||||||
|
var visibility any
|
||||||
|
if m.Visibility != "" {
|
||||||
|
visibility = string(m.Visibility)
|
||||||
|
} // else: 留 nil → COALESCE($15, 'private')
|
||||||
|
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO models (
|
INSERT INTO models (
|
||||||
id, owner_user_id, name, description, storage_key, file_size,
|
id, owner_user_id, name, description, storage_key, file_size,
|
||||||
file_checksum, faa_object_key, target_chip, input_shape, classes, framework,
|
file_checksum, faa_object_key, target_chip, input_shape, classes, framework,
|
||||||
source, source_job_id, created_at, updated_at, uploaded_at, deleted_at
|
source, source_job_id, visibility, created_at, updated_at, uploaded_at, deleted_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6,
|
$1, $2, $3, $4, $5, $6,
|
||||||
$7, $8, $9, $10, $11, $12,
|
$7, $8, $9, $10, $11, $12,
|
||||||
$13, $14, COALESCE($15, now()), now(), $16, $17
|
$13, $14, COALESCE($15, 'private'), COALESCE($16, now()), now(), $17, $18
|
||||||
)
|
)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
owner_user_id = EXCLUDED.owner_user_id,
|
owner_user_id = EXCLUDED.owner_user_id,
|
||||||
@ -163,6 +170,7 @@ func (r *PostgresRepository) Save(ctx context.Context, m *Model) error {
|
|||||||
framework = EXCLUDED.framework,
|
framework = EXCLUDED.framework,
|
||||||
source = EXCLUDED.source,
|
source = EXCLUDED.source,
|
||||||
source_job_id = EXCLUDED.source_job_id,
|
source_job_id = EXCLUDED.source_job_id,
|
||||||
|
visibility = EXCLUDED.visibility,
|
||||||
-- 保留原 created_at 僅當既有列未刪除;已刪除(復活)或值不同則用新值。
|
-- 保留原 created_at 僅當既有列未刪除;已刪除(復活)或值不同則用新值。
|
||||||
created_at = CASE
|
created_at = CASE
|
||||||
WHEN models.deleted_at IS NULL THEN models.created_at
|
WHEN models.deleted_at IS NULL THEN models.created_at
|
||||||
@ -187,9 +195,10 @@ func (r *PostgresRepository) Save(ctx context.Context, m *Model) error {
|
|||||||
m.Framework, // $12
|
m.Framework, // $12
|
||||||
string(m.Source), // $13
|
string(m.Source), // $13
|
||||||
nullableUUID(m.SourceJobID), // $14
|
nullableUUID(m.SourceJobID), // $14
|
||||||
createdAt, // $15
|
visibility, // $15
|
||||||
m.UploadedAt, // $16
|
createdAt, // $16
|
||||||
m.DeletedAt, // $17
|
m.UploadedAt, // $17
|
||||||
|
m.DeletedAt, // $18
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("model: pg Save upsert: %w", err)
|
return fmt.Errorf("model: pg Save upsert: %w", err)
|
||||||
@ -254,6 +263,7 @@ func scanModel(row rowScanner) (*Model, error) {
|
|||||||
&framework,
|
&framework,
|
||||||
&m.Source,
|
&m.Source,
|
||||||
&sourceJobID,
|
&sourceJobID,
|
||||||
|
&m.Visibility,
|
||||||
&m.CreatedAt,
|
&m.CreatedAt,
|
||||||
&m.UpdatedAt,
|
&m.UpdatedAt,
|
||||||
&m.UploadedAt,
|
&m.UploadedAt,
|
||||||
|
|||||||
414
visionA-backend/internal/model/postgres_sharing.go
Normal file
414
visionA-backend/internal/model/postgres_sharing.go
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
// postgres_sharing.go — PostgresRepository 的模型共享方法(Library 查詢 + model_shares CRUD)。
|
||||||
|
//
|
||||||
|
// 對齊:
|
||||||
|
// - feature-model-sharing-tdd.md §4(可見性 predicate + query 形狀 + 效能考量)
|
||||||
|
// - api/api-model-sharing.md §1(library:cursor 分頁 / sort / filter / q)
|
||||||
|
// - adr-017-model-library-access.md 決策 3(model_shares schema)
|
||||||
|
// - migrations/0006_model_sharing.up.sql(visibility 欄 + model_shares 表 + index)
|
||||||
|
//
|
||||||
|
// 可見性 predicate(single source of truth 的 SQL 展開,對齊 TDD §4.1):
|
||||||
|
//
|
||||||
|
// 可見(model, user) =
|
||||||
|
// owner_user_id = :userID -- 我的
|
||||||
|
// OR visibility = 'public' -- 全平台
|
||||||
|
// OR (visibility = 'tenant' AND owner.org_id = :orgID -- 同租戶
|
||||||
|
// AND :orgID <> '' AND owner.org_id IS NOT NULL)
|
||||||
|
// OR EXISTS (model_shares 命中 grantee=:userID) -- 分享給我
|
||||||
|
//
|
||||||
|
// tenant 邊界(SEC-4):org_id 兩者皆非空才可能命中,空 org 一律不落 tenant 可見。
|
||||||
|
// OIDC 現況不帶 org claim → :orgID 恆空 → tenant 集合恆空(安全預設),schema 就緒待 OIDC 補齊。
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// libraryColumns 是 Library 查詢的 SELECT 欄位(m.* + owner join + share 資訊)。
|
||||||
|
// 順序必須與 scanLibraryItem 對齊。內部 key(storage_key / faa_object_key)雖 SELECT
|
||||||
|
// 出來供 domain Model 完整(download 端點需 FAAObjectKey),但 DTO 序列化層(api)不揭露。
|
||||||
|
const libraryColumns = `m.id, m.owner_user_id, m.name, m.description, m.storage_key, m.file_size,
|
||||||
|
m.file_checksum, m.faa_object_key, m.target_chip, m.input_shape, m.classes, m.framework,
|
||||||
|
m.source, m.source_job_id, m.visibility, m.created_at, m.updated_at, m.uploaded_at, m.deleted_at,
|
||||||
|
COALESCE(u.name, '') AS owner_name, COALESCE(u.org_id::text, '') AS owner_org_id,
|
||||||
|
(s.grantee_user_id IS NOT NULL) AS shared_with_me, COALESCE(s.role, '') AS share_role`
|
||||||
|
|
||||||
|
// Library 依查詢 user 身份列出可見 model(cursor 分頁)。見檔頭 predicate 說明。
|
||||||
|
//
|
||||||
|
// query 形狀(對齊 TDD §4.2):單一 SELECT + JOIN users(取 owner.name / owner.org_id,
|
||||||
|
// 一次帶出避免 handler N+1)+ LEFT JOIN model_shares(取當前 user 的 share role / shared_with_me)。
|
||||||
|
// filter / 排序 / keyset cursor 皆參數化拼接(無字串拼接使用者輸入)。
|
||||||
|
func (r *PostgresRepository) Library(ctx context.Context, q LibraryQuery) ([]*LibraryItem, bool, error) {
|
||||||
|
var args []any
|
||||||
|
arg := func(v any) string { // 追加參數並回傳其 $N placeholder
|
||||||
|
args = append(args, v)
|
||||||
|
return fmt.Sprintf("$%d", len(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDP := arg(q.UserID)
|
||||||
|
// orgID:空字串時仍傳入,SQL 內以 `<> ''` 判非空(tenant 邊界 SEC-4)。
|
||||||
|
orgIDP := arg(q.UserOrgID)
|
||||||
|
|
||||||
|
// 可見性 predicate(TDD §4.1)。model_shares 子查用 m.id 關聯(相關子查)。
|
||||||
|
visPredicate := fmt.Sprintf(`(
|
||||||
|
m.owner_user_id = %[1]s
|
||||||
|
OR m.visibility = 'public'
|
||||||
|
OR (m.visibility = 'tenant' AND u.org_id IS NOT NULL AND u.org_id::text = %[2]s AND %[2]s <> '')
|
||||||
|
OR EXISTS (SELECT 1 FROM model_shares ms
|
||||||
|
WHERE ms.model_id = m.id AND ms.grantee_user_id = %[1]s)
|
||||||
|
)`, userIDP, orgIDP)
|
||||||
|
|
||||||
|
conds := []string{
|
||||||
|
"m.deleted_at IS NULL",
|
||||||
|
"m.uploaded_at IS NOT NULL", // 共享庫只列 ready
|
||||||
|
visPredicate,
|
||||||
|
}
|
||||||
|
|
||||||
|
// filter:owned 維度。
|
||||||
|
if q.Owned != nil {
|
||||||
|
if *q.Owned {
|
||||||
|
conds = append(conds, "m.owner_user_id = "+userIDP)
|
||||||
|
} else {
|
||||||
|
conds = append(conds, "m.owner_user_id <> "+userIDP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.TargetChip != "" {
|
||||||
|
conds = append(conds, "m.target_chip = "+arg(q.TargetChip))
|
||||||
|
}
|
||||||
|
if q.Source != "" {
|
||||||
|
conds = append(conds, "m.source = "+arg(q.Source))
|
||||||
|
}
|
||||||
|
// visibility filter:僅 public / tenant 有意義(private 不在共享庫語意內,忽略)。
|
||||||
|
if q.Visibility == VisibilityPublic || q.Visibility == VisibilityTenant {
|
||||||
|
conds = append(conds, "m.visibility = "+arg(q.Visibility))
|
||||||
|
}
|
||||||
|
if q.Q != "" {
|
||||||
|
// ILIKE 包含式搜尋 name + description(TDD §5:第一階段 ILIKE,量大再上 FTS)。
|
||||||
|
// 參數化 + 手動 escape LIKE 萬用字元,避免使用者輸入的 % / _ 改變語意。
|
||||||
|
like := "%" + escapeLike(q.Q) + "%"
|
||||||
|
p := arg(like)
|
||||||
|
conds = append(conds, "(m.name ILIKE "+p+" ESCAPE '\\' OR COALESCE(m.description, '') ILIKE "+p+" ESCAPE '\\')")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序欄位白名單(handler 已 validate,這裡再次以 switch 白名單防禦,杜絕 SQL 注入)。
|
||||||
|
sortCol := "m.created_at"
|
||||||
|
switch q.Sort {
|
||||||
|
case "name":
|
||||||
|
sortCol = "m.name"
|
||||||
|
case "file_size":
|
||||||
|
sortCol = "m.file_size"
|
||||||
|
case "created_at", "":
|
||||||
|
sortCol = "m.created_at"
|
||||||
|
}
|
||||||
|
dir := "DESC"
|
||||||
|
cmpOp := "<"
|
||||||
|
if q.Order == "asc" {
|
||||||
|
dir = "ASC"
|
||||||
|
cmpOp = ">"
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyset cursor:WHERE (sortCol, id) </> (cursorSortValue, cursorID)。
|
||||||
|
// 用 row-value 比較保證與 ORDER BY (sortCol, id) 一致的穩定分頁。
|
||||||
|
if q.Cursor != nil {
|
||||||
|
sv := castCursorValue(q.Sort, q.Cursor.SortValue)
|
||||||
|
svP := arg(sv.value)
|
||||||
|
idP := arg(q.Cursor.ID)
|
||||||
|
conds = append(conds, fmt.Sprintf("(%s, m.id) %s (%s%s, %s)", sortCol, cmpOp, svP, sv.cast, idP))
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := q.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
// 多取一筆判 hasMore。
|
||||||
|
limitP := arg(limit + 1)
|
||||||
|
|
||||||
|
query := `SELECT ` + libraryColumns + `
|
||||||
|
FROM models m
|
||||||
|
JOIN users u ON u.id = m.owner_user_id
|
||||||
|
LEFT JOIN model_shares s ON s.model_id = m.id AND s.grantee_user_id = ` + userIDP + `
|
||||||
|
WHERE ` + joinAnd(conds) + `
|
||||||
|
ORDER BY ` + sortCol + ` ` + dir + `, m.id ` + dir + `
|
||||||
|
LIMIT ` + limitP
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("model: pg Library query: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]*LibraryItem, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
it, scanErr := scanLibraryItem(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, false, fmt.Errorf("model: pg Library scan: %w", scanErr)
|
||||||
|
}
|
||||||
|
items = append(items, it)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, false, fmt.Errorf("model: pg Library rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMore := false
|
||||||
|
if len(items) > limit {
|
||||||
|
hasMore = true
|
||||||
|
items = items[:limit]
|
||||||
|
}
|
||||||
|
return items, hasMore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cursorCast 描述 cursor 排序值的 SQL 值 + 型別 cast(讓 row-value 比較型別對齊欄位)。
|
||||||
|
type cursorCast struct {
|
||||||
|
value string
|
||||||
|
cast string // 附加在 placeholder 後的 ::type,如 "::bigint" / "::timestamptz";name 為空
|
||||||
|
}
|
||||||
|
|
||||||
|
// castCursorValue 依 sort 欄位決定 cursor 值的型別 cast(避免 text 與欄位型別不符)。
|
||||||
|
func castCursorValue(sortField, raw string) cursorCast {
|
||||||
|
switch sortField {
|
||||||
|
case "file_size":
|
||||||
|
return cursorCast{value: raw, cast: "::bigint"}
|
||||||
|
case "name":
|
||||||
|
return cursorCast{value: raw, cast: ""}
|
||||||
|
default: // created_at
|
||||||
|
return cursorCast{value: raw, cast: "::timestamptz"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanLibraryItem 掃出一列 LibraryItem。欄位順序須對齊 libraryColumns。
|
||||||
|
func scanLibraryItem(row rowScanner) (*LibraryItem, error) {
|
||||||
|
var (
|
||||||
|
m Model
|
||||||
|
description *string
|
||||||
|
fileChecksum *string
|
||||||
|
faaObjectKey *string
|
||||||
|
targetChip *string
|
||||||
|
inputShape []int32
|
||||||
|
framework *string
|
||||||
|
sourceJobID *string
|
||||||
|
ownerName string
|
||||||
|
ownerOrgID string
|
||||||
|
sharedWithMe bool
|
||||||
|
shareRole string
|
||||||
|
)
|
||||||
|
err := row.Scan(
|
||||||
|
&m.ID, &m.OwnerUserID, &m.Name, &description, &m.StorageKey, &m.FileSize,
|
||||||
|
&fileChecksum, &faaObjectKey, &targetChip, &inputShape, &m.Classes, &framework,
|
||||||
|
&m.Source, &sourceJobID, &m.Visibility, &m.CreatedAt, &m.UpdatedAt, &m.UploadedAt, &m.DeletedAt,
|
||||||
|
&ownerName, &ownerOrgID, &sharedWithMe, &shareRole,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.Description = derefString(description)
|
||||||
|
m.FileChecksum = derefString(fileChecksum)
|
||||||
|
m.FAAObjectKey = derefString(faaObjectKey)
|
||||||
|
m.TargetChip = derefString(targetChip)
|
||||||
|
m.Framework = derefString(framework)
|
||||||
|
m.SourceJobID = derefString(sourceJobID)
|
||||||
|
m.InputShape = toIntSlice(inputShape)
|
||||||
|
m.CreatedAt = m.CreatedAt.UTC()
|
||||||
|
m.UpdatedAt = m.UpdatedAt.UTC()
|
||||||
|
if m.UploadedAt != nil {
|
||||||
|
u := m.UploadedAt.UTC()
|
||||||
|
m.UploadedAt = &u
|
||||||
|
}
|
||||||
|
|
||||||
|
// my_access:owner > share.role > public/tenant(viewer)。owner 由呼叫端已知(owner_user_id=userID),
|
||||||
|
// 但此處 Library 已用 predicate 過濾出可見列,故 access 一定 != none。
|
||||||
|
// owner 的判斷在 handler(is_me),這裡計算「非 owner 情境」的 access;owner 情境 handler 覆寫為 owner。
|
||||||
|
access := AccessViewer
|
||||||
|
if sharedWithMe && shareRole == "editor" {
|
||||||
|
access = AccessEditor
|
||||||
|
}
|
||||||
|
return &LibraryItem{
|
||||||
|
Model: &m,
|
||||||
|
OwnerName: ownerName,
|
||||||
|
OwnerOrgID: ownerOrgID,
|
||||||
|
SharedWithMe: sharedWithMe,
|
||||||
|
MyAccess: access,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWithOwner 取單一未刪除 Model + owner 顯示名稱(一次 JOIN users,供 profile 顯示 owner name)。
|
||||||
|
// 不存在或已軟刪回 ErrNotFound。
|
||||||
|
func (r *PostgresRepository) GetWithOwner(ctx context.Context, id string) (*Model, string, error) {
|
||||||
|
q := `SELECT ` + prefixCols("m", modelColumns) + `, COALESCE(u.name, '') AS owner_name
|
||||||
|
FROM models m
|
||||||
|
JOIN users u ON u.id = m.owner_user_id
|
||||||
|
WHERE m.id = $1 AND m.deleted_at IS NULL`
|
||||||
|
|
||||||
|
var ownerName string
|
||||||
|
row := r.pool.QueryRow(ctx, q, id)
|
||||||
|
m, err := scanModelWithExtra(row, &ownerName)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, "", ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("model: pg GetWithOwner: %w", err)
|
||||||
|
}
|
||||||
|
return m, ownerName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixCols 把 modelColumns 的每個裸欄名加上 table alias 前綴(`id` → `m.id`)。
|
||||||
|
// modelColumns 是不含前綴的欄位清單;GetWithOwner 需 alias 以區分 join 的 users 欄。
|
||||||
|
func prefixCols(alias, cols string) string {
|
||||||
|
parts := strings.Split(cols, ",")
|
||||||
|
for i, p := range parts {
|
||||||
|
parts[i] = alias + "." + strings.TrimSpace(p)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanModelWithExtra 掃出 *Model 後,再把 owner_name 掃進 extra(附加在 modelColumns 之後)。
|
||||||
|
// 為此需重掃:pgx row 只能 Scan 一次,故這裡直接展開 model 欄位 + extra 一起 Scan。
|
||||||
|
func scanModelWithExtra(row pgx.Row, ownerName *string) (*Model, error) {
|
||||||
|
var (
|
||||||
|
m Model
|
||||||
|
description *string
|
||||||
|
fileChecksum *string
|
||||||
|
faaObjectKey *string
|
||||||
|
targetChip *string
|
||||||
|
inputShape []int32
|
||||||
|
framework *string
|
||||||
|
sourceJobID *string
|
||||||
|
)
|
||||||
|
err := row.Scan(
|
||||||
|
&m.ID, &m.OwnerUserID, &m.Name, &description, &m.StorageKey, &m.FileSize,
|
||||||
|
&fileChecksum, &faaObjectKey, &targetChip, &inputShape, &m.Classes, &framework,
|
||||||
|
&m.Source, &sourceJobID, &m.Visibility, &m.CreatedAt, &m.UpdatedAt, &m.UploadedAt, &m.DeletedAt,
|
||||||
|
ownerName,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.Description = derefString(description)
|
||||||
|
m.FileChecksum = derefString(fileChecksum)
|
||||||
|
m.FAAObjectKey = derefString(faaObjectKey)
|
||||||
|
m.TargetChip = derefString(targetChip)
|
||||||
|
m.Framework = derefString(framework)
|
||||||
|
m.SourceJobID = derefString(sourceJobID)
|
||||||
|
m.InputShape = toIntSlice(inputShape)
|
||||||
|
m.CreatedAt = m.CreatedAt.UTC()
|
||||||
|
m.UpdatedAt = m.UpdatedAt.UTC()
|
||||||
|
if m.UploadedAt != nil {
|
||||||
|
u := m.UploadedAt.UTC()
|
||||||
|
m.UploadedAt = &u
|
||||||
|
}
|
||||||
|
if m.DeletedAt != nil {
|
||||||
|
d := m.DeletedAt.UTC()
|
||||||
|
m.DeletedAt = &d
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// model_shares CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GetShare 取得 (modelID, granteeUserID) 的分享;不存在回 ErrNotFound。
|
||||||
|
func (r *PostgresRepository) GetShare(ctx context.Context, modelID, granteeUserID string) (*ModelShare, error) {
|
||||||
|
const q = `SELECT model_id, grantee_user_id, role, granted_by, created_at
|
||||||
|
FROM model_shares WHERE model_id = $1 AND grantee_user_id = $2`
|
||||||
|
var s ModelShare
|
||||||
|
err := r.pool.QueryRow(ctx, q, modelID, granteeUserID).
|
||||||
|
Scan(&s.ModelID, &s.GranteeUserID, &s.Role, &s.GrantedBy, &s.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("model: pg GetShare: %w", err)
|
||||||
|
}
|
||||||
|
s.CreatedAt = s.CreatedAt.UTC()
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListShares 列出某 model 的所有分享(owner 檢視授權清單)。
|
||||||
|
func (r *PostgresRepository) ListShares(ctx context.Context, modelID string) ([]*ModelShare, error) {
|
||||||
|
const q = `SELECT model_id, grantee_user_id, role, granted_by, created_at
|
||||||
|
FROM model_shares WHERE model_id = $1 ORDER BY created_at ASC`
|
||||||
|
rows, err := r.pool.Query(ctx, q, modelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("model: pg ListShares: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]*ModelShare, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var s ModelShare
|
||||||
|
if err := rows.Scan(&s.ModelID, &s.GranteeUserID, &s.Role, &s.GrantedBy, &s.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("model: pg ListShares scan: %w", err)
|
||||||
|
}
|
||||||
|
s.CreatedAt = s.CreatedAt.UTC()
|
||||||
|
out = append(out, &s)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("model: pg ListShares rows: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertShare 新增 / 更新一筆分享(by PK (model_id, grantee_user_id))。重複 grantee → 更新 role。
|
||||||
|
func (r *PostgresRepository) UpsertShare(ctx context.Context, s *ModelShare) error {
|
||||||
|
if s == nil || s.ModelID == "" || s.GranteeUserID == "" {
|
||||||
|
return errors.New("model: UpsertShare requires modelID and granteeUserID")
|
||||||
|
}
|
||||||
|
role := s.Role
|
||||||
|
if role == "" {
|
||||||
|
role = "viewer"
|
||||||
|
}
|
||||||
|
const q = `INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (model_id, grantee_user_id) DO UPDATE SET
|
||||||
|
role = EXCLUDED.role, granted_by = EXCLUDED.granted_by`
|
||||||
|
if _, err := r.pool.Exec(ctx, q, s.ModelID, s.GranteeUserID, role, s.GrantedBy); err != nil {
|
||||||
|
return fmt.Errorf("model: pg UpsertShare: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteShare 移除 (modelID, granteeUserID) 的分享;不存在回 ErrNotFound。
|
||||||
|
func (r *PostgresRepository) DeleteShare(ctx context.Context, modelID, granteeUserID string) error {
|
||||||
|
const q = `DELETE FROM model_shares WHERE model_id = $1 AND grantee_user_id = $2`
|
||||||
|
tag, err := r.pool.Exec(ctx, q, modelID, granteeUserID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("model: pg DeleteShare: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// joinAnd 以 " AND " 串接 WHERE 條件。
|
||||||
|
func joinAnd(conds []string) string {
|
||||||
|
out := ""
|
||||||
|
for i, c := range conds {
|
||||||
|
if i > 0 {
|
||||||
|
out += " AND "
|
||||||
|
}
|
||||||
|
out += c
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeLike escape LIKE / ILIKE 的萬用字元(% _ \),避免使用者輸入改變 pattern 語意。
|
||||||
|
// 搭配查詢端的 `ESCAPE '\'`。
|
||||||
|
func escapeLike(s string) string {
|
||||||
|
var b []byte
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if c == '%' || c == '_' || c == '\\' {
|
||||||
|
b = append(b, '\\')
|
||||||
|
}
|
||||||
|
b = append(b, c)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
295
visionA-backend/internal/model/postgres_sharing_db_test.go
Normal file
295
visionA-backend/internal/model/postgres_sharing_db_test.go
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
//go:build dbtest
|
||||||
|
|
||||||
|
// PostgresRepository 模型共享方法(Library 查詢 + model_shares CRUD)的真 DB 整合測試。
|
||||||
|
//
|
||||||
|
// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。
|
||||||
|
// 執行:
|
||||||
|
//
|
||||||
|
// go test -tags=dbtest ./internal/model/...
|
||||||
|
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||||||
|
// go test -tags=dbtest ./internal/model/...
|
||||||
|
//
|
||||||
|
// 涵蓋:可見性 predicate(我的 ∪ public ∪ tenant同org ∪ shared)、enumeration 排除、
|
||||||
|
// filter / 搜尋 / cursor 分頁、share CRUD、tenant 邊界(空 org 不落 tenant)。
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"visiona-backend/internal/db/testsupport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// insertUserWithOrg 寫入一筆帶 org_id 的 user,回傳 user id。org 為空時 org_id=NULL。
|
||||||
|
func insertUserWithOrg(t *testing.T, tdb *testsupport.TestDB, org string) string {
|
||||||
|
t.Helper()
|
||||||
|
id := uuid.NewString()
|
||||||
|
ctx := context.Background()
|
||||||
|
if org == "" {
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO users (id, email) VALUES ($1, $2)`, id, id+"@t.local")
|
||||||
|
require.NoError(t, err)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO users (id, email, org_id) VALUES ($1, $2, $3)`, id, id+"@t.local", org)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveReady 存一個 ready model(指定 owner + visibility),回傳其 id。
|
||||||
|
func saveReady(t *testing.T, r *PostgresRepository, owner, visibility, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
id := uuid.NewString()
|
||||||
|
require.NoError(t, r.Save(context.Background(), &Model{
|
||||||
|
ID: id, OwnerUserID: owner, Name: name,
|
||||||
|
StorageKey: "models/" + owner + "/" + id + ".nef", FileSize: 1024,
|
||||||
|
Source: SourceUploaded, Visibility: visibility, UploadedAt: &now,
|
||||||
|
}))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGShare_LibraryVisibility 驗證 Library predicate(我的 ∪ public ∪ shared,排除別人 private)。
|
||||||
|
func TestPGShare_LibraryVisibility(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
tdb.Truncate(t, "model_shares", "models", "users")
|
||||||
|
r := NewPostgresRepository(tdb.Pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
me := insertUserWithOrg(t, tdb, "")
|
||||||
|
other := insertUserWithOrg(t, tdb, "")
|
||||||
|
|
||||||
|
mine := saveReady(t, r, me, VisibilityPrivate, "mine")
|
||||||
|
saveReady(t, r, other, VisibilityPrivate, "otherPriv")
|
||||||
|
pub := saveReady(t, r, other, VisibilityPublic, "otherPub")
|
||||||
|
shared := saveReady(t, r, other, VisibilityPrivate, "otherShared")
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: shared, GranteeUserID: me, Role: "viewer", GrantedBy: other}))
|
||||||
|
|
||||||
|
items, _, err := r.Library(ctx, LibraryQuery{UserID: me, Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got := map[string]*LibraryItem{}
|
||||||
|
for _, it := range items {
|
||||||
|
got[it.Model.ID] = it
|
||||||
|
}
|
||||||
|
assert.Contains(t, got, mine)
|
||||||
|
assert.Contains(t, got, pub)
|
||||||
|
assert.Contains(t, got, shared)
|
||||||
|
assert.Len(t, got, 3, "別人的 private 不應出現")
|
||||||
|
assert.True(t, got[shared].SharedWithMe)
|
||||||
|
assert.Equal(t, "viewer", got[shared].MyAccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGShare_LibraryTenantBoundary 驗證 tenant 可見性:同 org 命中、異 org / 空 org 不命中(SEC-4)。
|
||||||
|
func TestPGShare_LibraryTenantBoundary(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
tdb.Truncate(t, "model_shares", "models", "users")
|
||||||
|
r := NewPostgresRepository(tdb.Pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// org_id 是 UUID 欄,用真 UUID(不是 'org-1' 這種字面)。
|
||||||
|
org1 := uuid.NewString()
|
||||||
|
org2 := uuid.NewString()
|
||||||
|
orgOwner := insertUserWithOrg(t, tdb, org1)
|
||||||
|
teammate := insertUserWithOrg(t, tdb, org1)
|
||||||
|
outsider := insertUserWithOrg(t, tdb, org2)
|
||||||
|
noOrg := insertUserWithOrg(t, tdb, "")
|
||||||
|
|
||||||
|
tenantModel := saveReady(t, r, orgOwner, VisibilityTenant, "tenant")
|
||||||
|
|
||||||
|
// 同 org → 可見。UserOrgID 傳 org_id 的 text 形式(對齊 UserContext.OrgID 為字串)。
|
||||||
|
items, _, err := r.Library(ctx, LibraryQuery{UserID: teammate, UserOrgID: org1, Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
assert.Equal(t, tenantModel, items[0].Model.ID)
|
||||||
|
|
||||||
|
// 異 org → 不可見。
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: outsider, UserOrgID: org2, Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, items, "異 org 不應看到 tenant model")
|
||||||
|
|
||||||
|
// 空 org(OIDC 現況)→ 不可見(安全預設,即使 model 是 tenant)。
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: noOrg, UserOrgID: "", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, items, "空 org 不應落 tenant 可見(SEC-4)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGShare_LibraryFilters 驗證 filter(owned / target_chip / source / visibility / q)。
|
||||||
|
func TestPGShare_LibraryFilters(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
tdb.Truncate(t, "model_shares", "models", "users")
|
||||||
|
r := NewPostgresRepository(tdb.Pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
me := insertUserWithOrg(t, tdb, "")
|
||||||
|
other := insertUserWithOrg(t, tdb, "")
|
||||||
|
mine := saveReady(t, r, me, VisibilityPrivate, "yolo-mine")
|
||||||
|
pub := saveReady(t, r, other, VisibilityPublic, "resnet-pub")
|
||||||
|
|
||||||
|
// owned=true → 只我的。
|
||||||
|
items, _, err := r.Library(ctx, LibraryQuery{UserID: me, Owned: boolPtr(true), Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
assert.Equal(t, mine, items[0].Model.ID)
|
||||||
|
|
||||||
|
// owned=false → 只別人。
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Owned: boolPtr(false), Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
assert.Equal(t, pub, items[0].Model.ID)
|
||||||
|
|
||||||
|
// visibility=public。
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Visibility: VisibilityPublic, Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
assert.Equal(t, pub, items[0].Model.ID)
|
||||||
|
|
||||||
|
// q=yolo(搜尋 name)。
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Q: "yolo", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
assert.Equal(t, mine, items[0].Model.ID)
|
||||||
|
|
||||||
|
// q 含 LIKE 萬用字元應被 escape(不 match 全部)。
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Q: "%", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, items, "字面 '%' 不應 match 任何 model(萬用字元已 escape)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGShare_LibraryCursorPagination 驗證 cursor 分頁不重不漏(真 DB keyset)。
|
||||||
|
func TestPGShare_LibraryCursorPagination(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
tdb.Truncate(t, "model_shares", "models", "users")
|
||||||
|
r := NewPostgresRepository(tdb.Pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
me := insertUserWithOrg(t, tdb, "")
|
||||||
|
for i := 0; i < 7; i++ {
|
||||||
|
saveReady(t, r, me, VisibilityPrivate, "m"+string(rune('a'+i)))
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var cursor *Cursor
|
||||||
|
for page := 0; page < 20; page++ {
|
||||||
|
items, hasMore, err := r.Library(ctx, LibraryQuery{
|
||||||
|
UserID: me, Limit: 3, Sort: "name", Order: "asc", Cursor: cursor,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, it := range items {
|
||||||
|
assert.False(t, seen[it.Model.ID], "分頁重複 %s", it.Model.ID)
|
||||||
|
seen[it.Model.ID] = true
|
||||||
|
}
|
||||||
|
if !hasMore {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, items)
|
||||||
|
last := items[len(items)-1].Model
|
||||||
|
cursor = &Cursor{ID: last.ID, SortValue: last.Name}
|
||||||
|
}
|
||||||
|
assert.Len(t, seen, 7, "所有 model 應被分頁完整走過一次")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGShare_ShareCRUD 驗證 share Upsert / Get / List / Delete(真 DB)。
|
||||||
|
func TestPGShare_ShareCRUD(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
tdb.Truncate(t, "model_shares", "models", "users")
|
||||||
|
r := NewPostgresRepository(tdb.Pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
owner := insertUserWithOrg(t, tdb, "")
|
||||||
|
bob := insertUserWithOrg(t, tdb, "")
|
||||||
|
alice := insertUserWithOrg(t, tdb, "")
|
||||||
|
m := saveReady(t, r, owner, VisibilityPrivate, "m")
|
||||||
|
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: m, GranteeUserID: bob, Role: "viewer", GrantedBy: owner}))
|
||||||
|
got, err := r.GetShare(ctx, m, bob)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "viewer", got.Role)
|
||||||
|
|
||||||
|
// upsert 更新 role。
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: m, GranteeUserID: bob, Role: "editor", GrantedBy: owner}))
|
||||||
|
got, err = r.GetShare(ctx, m, bob)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "editor", got.Role)
|
||||||
|
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: m, GranteeUserID: alice, Role: "viewer", GrantedBy: owner}))
|
||||||
|
shares, err := r.ListShares(ctx, m)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, shares, 2)
|
||||||
|
|
||||||
|
require.NoError(t, r.DeleteShare(ctx, m, bob))
|
||||||
|
_, err = r.GetShare(ctx, m, bob)
|
||||||
|
assert.ErrorIs(t, err, ErrNotFound)
|
||||||
|
assert.ErrorIs(t, r.DeleteShare(ctx, m, uuid.NewString()), ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGShare_LibraryExcludesSoftDeletedAndPending 驗證軟刪 / 未 ready 的 model 不進 Library。
|
||||||
|
func TestPGShare_LibraryExcludesSoftDeletedAndPending(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
tdb.Truncate(t, "model_shares", "models", "users")
|
||||||
|
r := NewPostgresRepository(tdb.Pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
me := insertUserWithOrg(t, tdb, "")
|
||||||
|
// pending(無 UploadedAt)。
|
||||||
|
pendingID := uuid.NewString()
|
||||||
|
require.NoError(t, r.Save(ctx, &Model{
|
||||||
|
ID: pendingID, OwnerUserID: me, Name: "pending", StorageKey: "k",
|
||||||
|
FileSize: 1, Source: SourceUploaded, Visibility: VisibilityPublic,
|
||||||
|
}))
|
||||||
|
// ready 然後軟刪。
|
||||||
|
deleted := saveReady(t, r, me, VisibilityPublic, "deleted")
|
||||||
|
require.NoError(t, r.Delete(ctx, deleted))
|
||||||
|
// 正常 ready。
|
||||||
|
ok := saveReady(t, r, me, VisibilityPrivate, "ok")
|
||||||
|
|
||||||
|
items, _, err := r.Library(ctx, LibraryQuery{UserID: me, Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, items, 1, "只應列正常 ready 的 model")
|
||||||
|
assert.Equal(t, ok, items[0].Model.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGShare_GetWithOwner 驗證 GetWithOwner join 出 owner name(Minor-1,真 DB)。
|
||||||
|
func TestPGShare_GetWithOwner(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
tdb.Truncate(t, "model_shares", "models", "users")
|
||||||
|
r := NewPostgresRepository(tdb.Pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 建帶 name 的 owner。
|
||||||
|
ownerID := uuid.NewString()
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO users (id, email, name) VALUES ($1, $2, $3)`,
|
||||||
|
ownerID, ownerID+"@t.local", "Alice")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
modelID := saveReady(t, r, ownerID, VisibilityPublic, "m")
|
||||||
|
|
||||||
|
m, ownerName, err := r.GetWithOwner(ctx, modelID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, modelID, m.ID)
|
||||||
|
assert.Equal(t, VisibilityPublic, m.Visibility)
|
||||||
|
assert.Equal(t, "Alice", ownerName, "GetWithOwner 應 join 出 owner name")
|
||||||
|
|
||||||
|
// owner 無 name → 空字串(COALESCE)。
|
||||||
|
noNameOwner := insertUserWithOrg(t, tdb, "")
|
||||||
|
m2 := saveReady(t, r, noNameOwner, VisibilityPrivate, "m2")
|
||||||
|
_, ownerName2, err := r.GetWithOwner(ctx, m2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "", ownerName2, "owner 無 name 時 owner_name 應為空")
|
||||||
|
|
||||||
|
// 不存在 / 已軟刪 → ErrNotFound。
|
||||||
|
_, _, err = r.GetWithOwner(ctx, uuid.NewString())
|
||||||
|
assert.ErrorIs(t, err, ErrNotFound)
|
||||||
|
require.NoError(t, r.Delete(ctx, modelID))
|
||||||
|
_, _, err = r.GetWithOwner(ctx, modelID)
|
||||||
|
assert.ErrorIs(t, err, ErrNotFound, "已軟刪應回 ErrNotFound")
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPtr(b bool) *bool { return &b }
|
||||||
@ -185,5 +185,8 @@ func clonePreset(m *Model) *Model {
|
|||||||
t := *m.UploadedAt
|
t := *m.UploadedAt
|
||||||
cp.UploadedAt = &t
|
cp.UploadedAt = &t
|
||||||
}
|
}
|
||||||
|
// preset 是公用模型,語意等同全平台可見(visibility=public)。
|
||||||
|
// 在此統一標記,preset 宣告區不必逐筆設 Visibility。
|
||||||
|
cp.Visibility = VisibilityPublic
|
||||||
return &cp
|
return &cp
|
||||||
}
|
}
|
||||||
|
|||||||
17
visionA-backend/migrations/0006_model_sharing.down.sql
Normal file
17
visionA-backend/migrations/0006_model_sharing.down.sql
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
-- 0006_model_sharing.down.sql
|
||||||
|
--
|
||||||
|
-- 反向 0006:對稱移除 model_shares 表、models.visibility 欄與相關 index / constraint。
|
||||||
|
-- 順序:先刪依賴 visibility 的 partial index → 刪 model_shares 表(其 index 隨表 DROP 自動移除)
|
||||||
|
-- → 刪 models 的 constraint + 欄位。
|
||||||
|
|
||||||
|
-- (3) 共享庫查詢 index。
|
||||||
|
DROP INDEX IF EXISTS idx_models_public_active;
|
||||||
|
|
||||||
|
-- (2) model_shares 表(idx_model_shares_grantee 隨表 DROP 自動移除)。
|
||||||
|
DROP TABLE IF EXISTS model_shares;
|
||||||
|
|
||||||
|
-- (1) models.visibility 欄與其 CHECK constraint。
|
||||||
|
-- 先 DROP CONSTRAINT 再 DROP COLUMN(DROP COLUMN 也會連帶移除 constraint,
|
||||||
|
-- 此處顯式先移以求對稱清楚)。
|
||||||
|
ALTER TABLE models DROP CONSTRAINT IF EXISTS chk_models_visibility;
|
||||||
|
ALTER TABLE models DROP COLUMN IF EXISTS visibility;
|
||||||
49
visionA-backend/migrations/0006_model_sharing.up.sql
Normal file
49
visionA-backend/migrations/0006_model_sharing.up.sql
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
-- 0006_model_sharing.up.sql
|
||||||
|
--
|
||||||
|
-- 模型共享(Model Sharing)L 級新功能。在既有 owner-only 模型庫上,疊加兩個正交維度:
|
||||||
|
-- (1) visibility 廣播欄(private / tenant / public)— models 表加 enum 欄。
|
||||||
|
-- (2) model_shares 點對點分享表(ADR-017 決策 3 B1)— 分享給特定 user。
|
||||||
|
--
|
||||||
|
-- 對齊:docs/autoflow/04-architecture/feature-model-sharing-tdd.md §3、
|
||||||
|
-- docs/autoflow/04-architecture/api/api-model-sharing.md、
|
||||||
|
-- docs/autoflow/04-architecture/adr/adr-017-model-library-access.md 決策 3。
|
||||||
|
--
|
||||||
|
-- 環境事實(與 0001–0005 相同,已驗證):PostgreSQL 14.23,gen_random_uuid() 內建可直接用。
|
||||||
|
--
|
||||||
|
-- ★關鍵相容性:models.visibility DEFAULT 'private' → 既有所有 model 遷移後維持 owner-only
|
||||||
|
-- 語意,零行為改變。使用者要主動 PATCH visibility 才會公開。
|
||||||
|
|
||||||
|
-- ── (1) models 加 visibility 欄(廣播式公開對象)─────────────────────────────
|
||||||
|
-- 'private'(僅擁有者,= 現況預設)| 'tenant'(同租戶可見)| 'public'(全平台可見)
|
||||||
|
-- 全部既有 row 加欄後為 'private'(DEFAULT),語意完全等同遷移前的 owner-only。
|
||||||
|
ALTER TABLE models ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private';
|
||||||
|
ALTER TABLE models ADD CONSTRAINT chk_models_visibility
|
||||||
|
CHECK (visibility IN ('private', 'tenant', 'public'));
|
||||||
|
|
||||||
|
-- ── (2) model_shares 表(點對點分享,ADR-017 決策 3 B1,本功能沿用不重造)──────
|
||||||
|
-- role:'viewer'(可 list/get/download)| 'editor'(可改 metadata;本期讀取端用,寫入權後續)。
|
||||||
|
-- PK (model_id, grantee_user_id):同一 model 對同一 grantee 只有一筆分享(重複分享 = upsert)。
|
||||||
|
-- FK ON DELETE CASCADE:model 硬刪時連帶清 share(雖然本系統 model 為軟刪,CASCADE 為防禦性
|
||||||
|
-- 一致——若未來真硬刪不留孤兒列;軟刪時 share 保留,由查詢端 join models.deleted_at 過濾)。
|
||||||
|
CREATE TABLE model_shares (
|
||||||
|
model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
||||||
|
grantee_user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer',
|
||||||
|
granted_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (model_id, grantee_user_id),
|
||||||
|
CONSTRAINT chk_model_shares_role CHECK (role IN ('viewer', 'editor'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- grantee 反查(共享庫「分享給我」predicate 的 EXISTS 子查走此 index)。
|
||||||
|
CREATE INDEX idx_model_shares_grantee ON model_shares (grantee_user_id);
|
||||||
|
|
||||||
|
-- ── (3) 共享庫查詢用 index ───────────────────────────────────────────────────
|
||||||
|
-- public 全平台可見列表:high-selectivity partial index(沿用既有 models index 的
|
||||||
|
-- `WHERE deleted_at IS NULL` 慣例)。只索引 public 且未刪除且已上傳(ready)的 model,
|
||||||
|
-- 共享庫預設按 created_at DESC 排序、此 index 直接覆蓋該掃描。
|
||||||
|
CREATE INDEX idx_models_public_active ON models (created_at DESC)
|
||||||
|
WHERE deleted_at IS NULL AND visibility = 'public' AND uploaded_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- tenant 可見需 join users 取 owner.org_id;users 主鍵 join 成本低,
|
||||||
|
-- owner 維度沿用既有 idx_models_owner_active,不另建。
|
||||||
Loading…
x
Reference in New Issue
Block a user