Compare commits
3 Commits
e4d27594d6
...
6a797d5eb5
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a797d5eb5 | |||
| 47a1d4d0ef | |||
| 17134e8eae |
@ -178,6 +178,8 @@ vendor-ffmpeg-macos-build: ## macOS:從源碼 build LGPL v3 decoder-only ffmpe
|
||||
--disable-everything \
|
||||
--enable-small \
|
||||
--enable-protocol=file,pipe \
|
||||
--enable-avfoundation \
|
||||
--enable-indev=avfoundation \
|
||||
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 \
|
||||
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be \
|
||||
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac \
|
||||
|
||||
@ -17,7 +17,7 @@ import (
|
||||
const stderrTailBytes = 8 * 1024
|
||||
|
||||
// FFmpegCamera captures webcam frames using ffmpeg subprocess.
|
||||
// Supports macOS (AVFoundation) and Windows (DirectShow).
|
||||
// Supports macOS (AVFoundation), Windows (DirectShow) and Linux (Video4Linux2).
|
||||
// ffmpeg outputs a continuous MJPEG stream to stdout which is parsed
|
||||
// by scanning for JPEG SOI (0xFFD8) and EOI (0xFFD9) markers.
|
||||
type FFmpegCamera struct {
|
||||
@ -167,10 +167,33 @@ func (c *FFmpegCamera) StderrTail() string {
|
||||
|
||||
// buildCaptureArgs returns the ffmpeg arguments for the current OS.
|
||||
func buildCaptureArgs(cameraIndex int, cameraName string, width, height, framerate int) []string {
|
||||
return buildCaptureArgsForOS(runtime.GOOS, cameraIndex, cameraName, width, height, framerate)
|
||||
}
|
||||
|
||||
// buildCaptureArgsForOS 依指定 goos 組 ffmpeg capture args,把平台判斷抽成參數以便
|
||||
// table test 三平台輸出(不用 mock runtime.GOOS)。三平台各用不同 input device(indev):
|
||||
// - macOS → avfoundation(-i "<index>:none")
|
||||
// - Windows → dshow(-i video="<name>")
|
||||
// - Linux → v4l2(-i /dev/video<N>)
|
||||
//
|
||||
// 三平台後段皆接 -f image2pipe -vcodec mjpeg -q:v 5 -an -,MJPEG pipe 架構共用。
|
||||
// 對照見 ADR-020 §2.2 三平台 capture args 對照表。
|
||||
func buildCaptureArgsForOS(goos string, cameraIndex int, cameraName string, width, height, framerate int) []string {
|
||||
videoSize := fmt.Sprintf("%dx%d", width, height)
|
||||
fps := fmt.Sprintf("%d", framerate)
|
||||
|
||||
switch runtime.GOOS {
|
||||
// captureTail 是三平台共用的輸出段(把 raw frame 轉成 stdout 上的 MJPEG stream)。
|
||||
captureTail := func(args []string) []string {
|
||||
return append(args,
|
||||
"-f", "image2pipe",
|
||||
"-vcodec", "mjpeg",
|
||||
"-q:v", "5",
|
||||
"-an",
|
||||
"-",
|
||||
)
|
||||
}
|
||||
|
||||
switch goos {
|
||||
case "windows":
|
||||
// DirectShow on Windows: -f dshow -i video="Camera Name"
|
||||
inputName := cameraName
|
||||
@ -183,30 +206,41 @@ func buildCaptureArgs(cameraIndex int, cameraName string, width, height, framera
|
||||
inputName = "Integrated Camera"
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
return captureTail([]string{
|
||||
"-f", "dshow",
|
||||
"-framerate", fps,
|
||||
"-video_size", videoSize,
|
||||
"-i", fmt.Sprintf("video=%s", inputName),
|
||||
"-f", "image2pipe",
|
||||
"-vcodec", "mjpeg",
|
||||
"-q:v", "5",
|
||||
"-an",
|
||||
"-",
|
||||
}
|
||||
default:
|
||||
})
|
||||
case "linux":
|
||||
// Video4Linux2 on Linux: -f v4l2 -i /dev/video<N>
|
||||
// v4l2 以裝置節點路徑(非 index)指定攝影機;cameraIndex 對應 /dev/video<N>,
|
||||
// 這是 Linux 攝影機的慣例(/dev/video0 = 第一支)。cameraName 在 Linux 未使用。
|
||||
return captureTail([]string{
|
||||
"-f", "v4l2",
|
||||
"-framerate", fps,
|
||||
"-video_size", videoSize,
|
||||
"-i", fmt.Sprintf("/dev/video%d", cameraIndex),
|
||||
})
|
||||
case "darwin":
|
||||
// AVFoundation on macOS: -f avfoundation -i "index:none"
|
||||
return []string{
|
||||
return captureTail([]string{
|
||||
"-f", "avfoundation",
|
||||
"-framerate", fps,
|
||||
"-video_size", videoSize,
|
||||
"-i", fmt.Sprintf("%d:none", cameraIndex),
|
||||
"-f", "image2pipe",
|
||||
"-vcodec", "mjpeg",
|
||||
"-q:v", "5",
|
||||
"-an",
|
||||
"-",
|
||||
}
|
||||
})
|
||||
default:
|
||||
// 未知平台:明確落到 macOS 的 avfoundation 是錯的(原本 default 就是這個 bug)。
|
||||
// 保留 avfoundation 作為最後手段,但這只是為了讓非三大平台不至於 build 失敗;
|
||||
// 實務上未知平台的 camera 抓取本就不支援,會在 ffmpeg 層以「Unknown input format」失敗。
|
||||
// 三大目標平台(darwin/windows/linux)都有明確 case、不會落到這裡。
|
||||
return captureTail([]string{
|
||||
"-f", "avfoundation",
|
||||
"-framerate", fps,
|
||||
"-video_size", videoSize,
|
||||
"-i", fmt.Sprintf("%d:none", cameraIndex),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
192
local-agent/server/internal/camera/ffmpeg_capture_args_test.go
Normal file
192
local-agent/server/internal/camera/ffmpeg_capture_args_test.go
Normal file
@ -0,0 +1,192 @@
|
||||
package camera
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildCaptureArgsForOS 驗證三平台各自用正確的 ffmpeg input device(indev):
|
||||
// macOS→avfoundation、Windows→dshow、Linux→v4l2。這是 ADR-020 的核心修復:
|
||||
// 原本 Linux 會誤落 default 分支用 avfoundation → 必壞。
|
||||
func TestBuildCaptureArgsForOS(t *testing.T) {
|
||||
const (
|
||||
width = 640
|
||||
height = 480
|
||||
fps = 30
|
||||
index = 0
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
cameraName string
|
||||
// wantInputFlag 是預期的 "-f <indev>" 值
|
||||
wantIndev string
|
||||
// wantInputArg 是 "-i" 後面的值
|
||||
wantInputArg string
|
||||
}{
|
||||
{
|
||||
name: "macOS uses avfoundation with index:none",
|
||||
goos: "darwin",
|
||||
wantIndev: "avfoundation",
|
||||
wantInputArg: "0:none",
|
||||
},
|
||||
{
|
||||
name: "Windows uses dshow with video=name",
|
||||
goos: "windows",
|
||||
cameraName: "Integrated Camera",
|
||||
wantIndev: "dshow",
|
||||
wantInputArg: "video=Integrated Camera",
|
||||
},
|
||||
{
|
||||
name: "Linux uses v4l2 with /dev/video path",
|
||||
goos: "linux",
|
||||
wantIndev: "v4l2",
|
||||
wantInputArg: "/dev/video0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
args := buildCaptureArgsForOS(tt.goos, index, tt.cameraName, width, height, fps)
|
||||
|
||||
gotIndev := valueAfterFlag(args, "-f") // 第一個 -f 是 input format
|
||||
if gotIndev != tt.wantIndev {
|
||||
t.Errorf("indev = %q, want %q\nargs: %v", gotIndev, tt.wantIndev, args)
|
||||
}
|
||||
|
||||
gotInput := valueAfterFlag(args, "-i")
|
||||
if gotInput != tt.wantInputArg {
|
||||
t.Errorf("input = %q, want %q\nargs: %v", gotInput, tt.wantInputArg, args)
|
||||
}
|
||||
|
||||
// 三平台後段皆須為 image2pipe / mjpeg,MJPEG pipe 架構共用。
|
||||
if !containsSeq(args, "-f", "image2pipe") {
|
||||
t.Errorf("missing image2pipe output, args: %v", args)
|
||||
}
|
||||
if !containsSeq(args, "-vcodec", "mjpeg") {
|
||||
t.Errorf("missing mjpeg vcodec, args: %v", args)
|
||||
}
|
||||
if args[len(args)-1] != "-" {
|
||||
t.Errorf("last arg should be stdout '-', got %q", args[len(args)-1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCaptureArgsForOS_LinuxIndexToDevicePath 驗證 Linux 的 cameraIndex 正確
|
||||
// 對應到 /dev/video<N> 節點路徑。
|
||||
func TestBuildCaptureArgsForOS_LinuxIndexToDevicePath(t *testing.T) {
|
||||
for _, idx := range []int{0, 1, 2, 10} {
|
||||
args := buildCaptureArgsForOS("linux", idx, "", 640, 480, 30)
|
||||
want := "/dev/video" + itoa(idx)
|
||||
if got := valueAfterFlag(args, "-i"); got != want {
|
||||
t.Errorf("index %d → input %q, want %q", idx, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCaptureArgsForOS_LinuxNotAVFoundation 是 ADR-020 的回歸鎖:Linux 絕不能
|
||||
// 用 avfoundation(macOS 專用)。若未來有人把 Linux case 拿掉、讓它落回 default,
|
||||
// 這個測試會抓到。
|
||||
func TestBuildCaptureArgsForOS_LinuxNotAVFoundation(t *testing.T) {
|
||||
args := buildCaptureArgsForOS("linux", 0, "", 640, 480, 30)
|
||||
joined := strings.Join(args, " ")
|
||||
if strings.Contains(joined, "avfoundation") {
|
||||
t.Fatalf("Linux must NOT use avfoundation (macOS-only), args: %v", args)
|
||||
}
|
||||
if !strings.Contains(joined, "v4l2") {
|
||||
t.Fatalf("Linux must use v4l2, args: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseV4L2Devices 驗證 /dev/video* 路徑清單解析成 CameraInfo,且依 index 數值
|
||||
// 排序(video10 排在 video2 之後、而非字典序)。
|
||||
func TestParseV4L2Devices(t *testing.T) {
|
||||
paths := []string{
|
||||
"/dev/video10",
|
||||
"/dev/video2",
|
||||
"/dev/video0",
|
||||
"/dev/video-not-a-number", // 應被略過
|
||||
"/dev/videoX", // 應被略過
|
||||
}
|
||||
|
||||
got := parseV4L2Devices(paths)
|
||||
|
||||
wantIndexes := []int{0, 2, 10}
|
||||
if len(got) != len(wantIndexes) {
|
||||
t.Fatalf("got %d devices, want %d: %+v", len(got), len(wantIndexes), got)
|
||||
}
|
||||
for i, want := range wantIndexes {
|
||||
if got[i].Index != want {
|
||||
t.Errorf("device[%d].Index = %d, want %d", i, got[i].Index, want)
|
||||
}
|
||||
if got[i].Name != "/dev/video"+itoa(want) {
|
||||
t.Errorf("device[%d].Name = %q, want /dev/video%d", i, got[i].Name, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseV4L2Devices_Empty 驗證無裝置時回 nil/空。
|
||||
func TestParseV4L2Devices_Empty(t *testing.T) {
|
||||
if got := parseV4L2Devices(nil); len(got) != 0 {
|
||||
t.Errorf("expected no devices, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestV4L2Index 驗證從路徑取 index。
|
||||
func TestV4L2Index(t *testing.T) {
|
||||
cases := map[string]int{
|
||||
"/dev/video0": 0,
|
||||
"/dev/video12": 12,
|
||||
"/dev/video": -1,
|
||||
"/dev/videoab": -1,
|
||||
"videoX": -1,
|
||||
}
|
||||
for path, want := range cases {
|
||||
if got := v4l2Index(path); got != want {
|
||||
t.Errorf("v4l2Index(%q) = %d, want %d", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
// valueAfterFlag 回傳 args 中第一個等於 flag 的元素的下一個值。
|
||||
func valueAfterFlag(args []string, flag string) string {
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == flag {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// containsSeq 判斷 args 是否包含連續的 a b 兩個元素。
|
||||
func containsSeq(args []string, a, b string) bool {
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == a && args[i+1] == b {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf []byte
|
||||
for n > 0 {
|
||||
buf = append([]byte{byte('0' + n%10)}, buf...)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
buf = append([]byte{'-'}, buf...)
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
@ -3,8 +3,10 @@ package camera
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@ -19,7 +21,14 @@ func DetectFFmpeg() bool {
|
||||
// Automatically selects the correct capture framework for the current OS:
|
||||
// - macOS: AVFoundation
|
||||
// - Windows: DirectShow (dshow)
|
||||
// - Linux: Video4Linux2 (v4l2, enumerated from /dev/video*)
|
||||
func ListFFmpegDevices() []CameraInfo {
|
||||
// Linux 走 /dev/video* 列舉、不依賴 ffmpeg binary(-list_devices 在部分 v4l2 build
|
||||
// 不穩定),故不要求 DetectFFmpeg 也能列出裝置節點;實際抓 frame 時才需要 ffmpeg。
|
||||
if runtime.GOOS == "linux" {
|
||||
return listV4L2Devices()
|
||||
}
|
||||
|
||||
if !DetectFFmpeg() {
|
||||
return nil
|
||||
}
|
||||
@ -132,3 +141,66 @@ func parseDShowOutput(output string) []CameraInfo {
|
||||
|
||||
return cameras
|
||||
}
|
||||
|
||||
// --- Linux (Video4Linux2) ---
|
||||
|
||||
// v4l2DeviceGlob 是列舉 v4l2 攝影機節點的 glob pattern。抽成變數讓測試可覆寫成
|
||||
// 假的目錄,不依賴實機 /dev。
|
||||
var v4l2DeviceGlob = "/dev/video*"
|
||||
|
||||
// v4l2DeviceIndexRe 從 /dev/video<N> 取出 index N。
|
||||
var v4l2DeviceIndexRe = regexp.MustCompile(`video(\d+)$`)
|
||||
|
||||
// listV4L2Devices 列舉 Linux 上的 v4l2 攝影機節點(/dev/video*)。
|
||||
//
|
||||
// 為何用 glob 而非 ffmpeg -list_devices:v4l2 indev 的 -list_devices 支援度依 ffmpeg
|
||||
// 版本而異、部分 build 不輸出可解析清單;直接列舉 /dev/video* 節點是最穩定的跨版本做法。
|
||||
//
|
||||
// 注意:/dev/video* 也包含非攝影機的 V4L2 節點(如 metadata / output device),這裡先
|
||||
// 全數列出、由使用者選擇;抓 frame 失敗會在 ffmpeg 層以明確錯誤浮現(camera 修 bug 後不再吞錯)。
|
||||
func listV4L2Devices() []CameraInfo {
|
||||
matches, err := filepath.Glob(v4l2DeviceGlob)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parseV4L2Devices(matches)
|
||||
}
|
||||
|
||||
// parseV4L2Devices 把 /dev/video* 路徑清單轉成 CameraInfo(依 index 排序)。
|
||||
// 抽出來讓測試可直接餵路徑清單、不碰檔案系統。
|
||||
func parseV4L2Devices(paths []string) []CameraInfo {
|
||||
// 依裝置 index 數值排序(Glob 回傳為字典序,video10 會排在 video2 前,需正規化)。
|
||||
sorted := append([]string(nil), paths...)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return v4l2Index(sorted[i]) < v4l2Index(sorted[j])
|
||||
})
|
||||
|
||||
var cameras []CameraInfo
|
||||
for _, path := range sorted {
|
||||
idx := v4l2Index(path)
|
||||
if idx < 0 {
|
||||
continue // 不是 /dev/video<N> 形式、略過
|
||||
}
|
||||
cameras = append(cameras, CameraInfo{
|
||||
ID: fmt.Sprintf("cam-%d", idx),
|
||||
Name: path, // Linux 以裝置節點路徑作為名稱(buildCaptureArgs 用 index 組回路徑)
|
||||
Index: idx,
|
||||
Width: 640,
|
||||
Height: 480,
|
||||
})
|
||||
}
|
||||
return cameras
|
||||
}
|
||||
|
||||
// v4l2Index 從 /dev/video<N> 取出 N;非該形式回 -1。
|
||||
func v4l2Index(path string) int {
|
||||
m := v4l2DeviceIndexRe.FindStringSubmatch(path)
|
||||
if len(m) != 2 {
|
||||
return -1
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
88
local-agent/vendor/ffmpeg/macos/BUILD.md
vendored
88
local-agent/vendor/ffmpeg/macos/BUILD.md
vendored
@ -18,17 +18,24 @@ LGPL static build 來源,採「自 build decoder-only」策略,binary 直接
|
||||
| Toolchain | Apple clang 16.0.0 (clang-1600.0.26.6), Command Line Tools |
|
||||
| Assembler | nasm 3.01(Homebrew bottle,compiled 2025-10-11) |
|
||||
| Homebrew | 5.1.6 |
|
||||
| Build date | 2026-04-15 |
|
||||
| Build date | 2026-08-02(ADR-020 rebuild:加回 avfoundation indev) |
|
||||
| Build flags | 見下方 Configure flags 區塊(與 `Makefile` 的 `vendor-ffmpeg-macos-build` target 一致) |
|
||||
|
||||
> **ADR-020 變更(2026-08-02)**:configure 加 `--enable-avfoundation` + `--enable-indev=avfoundation`,
|
||||
> 讓 camera 即時推論可用 avfoundation 抓實體攝影機。詳見
|
||||
> `docs/autoflow/04-architecture/adr/adr-020-ffmpeg-camera-indev.md`。
|
||||
> 之前的 decoder-only build(2026-04-15)沒編任何 indev,導致 `Unknown input format: 'avfoundation'`、camera 開不了。
|
||||
|
||||
## Binary sha256
|
||||
|
||||
| 檔案 | sha256 |
|
||||
|------|--------|
|
||||
| `ffmpeg` | `c3cb9f1dad66730267c12fca92c6344d2f8939ab227889caac33005f8947992c` |
|
||||
| `ffprobe` | `bd388fb4372ed5f7e44ee331a51be6383d702fb2c067bf562cabbdfbdd8b0c5e` |
|
||||
| `ffmpeg` | `1afa56dabb4ba4fb45323e37510602309ffd544d9e7ecb2d8267697a7cc16626` |
|
||||
| `ffprobe` | `501ec3fbe450c44f4c28cd7534940c6a16a42f8584e852b4f315de565aba414e` |
|
||||
| `COPYING.LGPLv3` | `da7eabb7bafdf7d3ae5e9f223aa5bdc1eece45ac569dc21b3b037520b4464768` |
|
||||
|
||||
> 舊值(2026-04-15 decoder-only、無 indev):ffmpeg `c3cb9f1d…992c` / ffprobe `bd388fb4…0c5e`。
|
||||
|
||||
計算指令:
|
||||
```bash
|
||||
shasum -a 256 vendor/ffmpeg/macos/ffmpeg vendor/ffmpeg/macos/ffprobe
|
||||
@ -38,17 +45,21 @@ shasum -a 256 vendor/ffmpeg/macos/ffmpeg vendor/ffmpeg/macos/ffprobe
|
||||
|
||||
| 檔案 | Bytes | 人類可讀 |
|
||||
|------|-------|---------|
|
||||
| `ffmpeg` | 6,007,520 | 5.7 MB |
|
||||
| `ffprobe` | 5,865,568 | 5.6 MB |
|
||||
| `ffmpeg` | 6,030,224 | 5.8 MB |
|
||||
| `ffprobe` | 5,892,384 | 5.6 MB |
|
||||
|
||||
實測比 TDD 原估 10–15 MB 小一半,因為 `--disable-everything` + 白名單僅啟用必要 decoder/demuxer/filter,無 GPL 元件。
|
||||
|
||||
> **avfoundation indev 體積增量(ADR-020)**:ffmpeg 6,007,520 → 6,030,224 bytes,僅
|
||||
> **+22,704 bytes(+0.02 MB)**,遠低於 ADR-020 估的 < 0.5 MB。因 avfoundation indev 是薄封裝,
|
||||
> 呼叫系統 AVFoundation / CoreMedia / CoreVideo framework,不自帶任何 codec。
|
||||
|
||||
### Build 實測耗時
|
||||
|
||||
- **2 分 44 秒**(`make vendor-ffmpeg-macos-build` 的 `time` 量測)
|
||||
- user: 559.60s,system: 56.03s,wall-clock: 164.56s
|
||||
- CPU 使用率:~374%(macOS x86_64,8 核 Intel)
|
||||
- 比 TDD 原估 10–20 分鐘快很多,因為 `--disable-everything` 大幅削減編譯單元數量
|
||||
- **3 分 57 秒**(2026-08-02 ADR-020 rebuild,`make vendor-ffmpeg-macos-build` 的 `time` 量測)
|
||||
- user: 870.32s,system: 92.27s,wall-clock: 236.62s
|
||||
- CPU 使用率:~406%(macOS x86_64,8 核 Intel)
|
||||
- (2026-04-15 首次 decoder-only build 為 2 分 44 秒;本次略增因多編 avfoundation indev)
|
||||
|
||||
## License
|
||||
|
||||
@ -64,6 +75,13 @@ build 不 link 以下 GPL-only 元件:
|
||||
僅使用 libavcodec 內建的 LGPL native decoder(h264 / hevc / mpeg1video / mpeg2video /
|
||||
mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_*)。
|
||||
|
||||
**avfoundation indev(ADR-020)為 LGPL-safe,不引入任何 GPL 元件**:avfoundation input device
|
||||
只是薄封裝、透過 macOS 系統的 AVFoundation / CoreMedia / CoreVideo framework 抓實體攝影機 frame,
|
||||
不含任何第三方 / GPL codec。加 `--enable-avfoundation` + `--enable-indev=avfoundation` 後,
|
||||
`ffmpeg -version` 的 configuration line 仍**不含** `--enable-gpl` / `libx264` / `libx265`(已實測驗證),
|
||||
`--enable-version3`(LGPL v3)合規未破。新增 link 的皆為 Apple 系統 framework(AVFoundation /
|
||||
Foundation / CoreGraphics / libobjc),非第三方 dylib。
|
||||
|
||||
---
|
||||
|
||||
## Configure flags(完整複製)
|
||||
@ -82,6 +100,8 @@ mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_*)。
|
||||
--disable-everything \
|
||||
--enable-small \
|
||||
--enable-protocol=file,pipe \
|
||||
--enable-avfoundation \
|
||||
--enable-indev=avfoundation \
|
||||
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 \
|
||||
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be \
|
||||
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac \
|
||||
@ -109,6 +129,8 @@ mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_*)。
|
||||
| `--disable-everything` | 先關全部,白名單 enable,確保不額外 link 任何 GPL 元件 |
|
||||
| `--enable-small` | 最佳化體積而非速度 |
|
||||
| `--enable-protocol=file,pipe` | 只開 file:// 和 pipe(ffmpeg 內部 stdin/stdout) |
|
||||
| `--enable-avfoundation` | **(ADR-020)** camera 抓實體攝影機需 AVFoundation framework。因本 build 用 `--disable-autodetect`(連 AVFoundation 框架都不自動偵測),必須顯式 `--enable-avfoundation` 才能讓下面的 `avfoundation` indev 的依賴(`avfoundation corevideo coremedia pthreads`)被滿足。**少了這行、`--enable-indev=avfoundation` 會被 configure 靜默 disable(`WARNING: Disabled avfoundation_indev because not all dependencies are satisfied`)** |
|
||||
| `--enable-indev=avfoundation` | **(ADR-020)** camera 即時推論的 macOS input device。ffmpeg `-f avfoundation -i "<index>:none"` 從實體攝影機抓 raw frame → MJPEG pipe。少了它會 `Unknown input format: 'avfoundation'`。LGPL-safe 薄封裝、體積增量 < 0.03MB |
|
||||
| `--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2` | 對齊 PRD v2 支援的上傳格式 `.mp4 / .avi / .mov / .mpeg / .mpg` |
|
||||
| `--enable-decoder=h264,hevc,...` | 涵蓋常見 codec:H.264 / H.265 / MPEG1/2/4 / mjpeg / prores / vp8/9 / AAC / MP2/3 / PCM |
|
||||
| `--enable-parser=...` | 必要,否則某些 decoder 會在碼流切分階段 fail |
|
||||
@ -192,6 +214,13 @@ codesign -v vendor/ffmpeg/macos/ffprobe
|
||||
vendor/ffmpeg/macos/ffmpeg -hide_banner -i <some-sample>.mp4 -f image2pipe -vcodec mjpeg -frames:v 1 -q:v 5 /tmp/test.jpg
|
||||
file /tmp/test.jpg
|
||||
# 預期:JPEG image data
|
||||
|
||||
# 7.(ADR-020)確認 avfoundation indev 有編進去、可列出攝影機
|
||||
vendor/ffmpeg/macos/ffmpeg -hide_banner -devices 2>&1 | grep avfoundation
|
||||
# 預期:D avfoundation
|
||||
vendor/ffmpeg/macos/ffmpeg -hide_banner -f avfoundation -list_devices true -i "" 2>&1
|
||||
# 預期:列出 AVFoundation video/audio devices(不再 Unknown input format: 'avfoundation')
|
||||
# 註:-list_devices true 列完裝置後會以非 0 退出(Error opening input),屬正常,非失敗。
|
||||
```
|
||||
|
||||
---
|
||||
@ -254,22 +283,28 @@ $ vendor/ffmpeg/macos/ffmpeg -hide_banner -formats 2>&1 \
|
||||
- `mpegts` — MPEG Transport Stream
|
||||
- `matroska,webm` — ok
|
||||
|
||||
### 4. Dynamic dependencies (`otool -L`)
|
||||
### 4. Dynamic dependencies (`otool -L`)(ADR-020 rebuild 後)
|
||||
|
||||
```
|
||||
vendor/ffmpeg/macos/ffmpeg:
|
||||
/System/Library/Frameworks/Foundation.framework/.../Foundation ← ADR-020 新增(avfoundation 依賴)
|
||||
/usr/lib/libSystem.B.dylib
|
||||
/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
|
||||
/System/Library/Frameworks/CoreVideo.framework/Versions/A/CoreVideo
|
||||
/System/Library/Frameworks/CoreMedia.framework/Versions/A/CoreMedia
|
||||
/System/Library/Frameworks/AVFoundation.framework/.../AVFoundation ← ADR-020 新增
|
||||
/System/Library/Frameworks/CoreVideo.framework/.../CoreVideo
|
||||
/System/Library/Frameworks/CoreMedia.framework/.../CoreMedia
|
||||
/System/Library/Frameworks/CoreGraphics.framework/.../CoreGraphics ← ADR-020 新增(avfoundation suggest)
|
||||
/System/Library/Frameworks/CoreFoundation.framework/.../CoreFoundation
|
||||
/usr/lib/libobjc.A.dylib ← ADR-020 新增(Objective-C runtime)
|
||||
|
||||
vendor/ffmpeg/macos/ffprobe:
|
||||
(同上四個 macOS system framework)
|
||||
(同上一組 macOS system framework)
|
||||
```
|
||||
|
||||
- ✅ 只依賴 macOS 系統內建 framework(`libSystem`, `CoreFoundation`, `CoreVideo`, `CoreMedia`)
|
||||
- ✅ 只依賴 macOS 系統內建 framework / dylib(AVFoundation / Foundation / CoreVideo / CoreMedia /
|
||||
CoreGraphics / CoreFoundation / libSystem / libobjc)
|
||||
- ✅ **無任何第三方 dylib**(`libx264`, `libx265`, `libvpx`, `libopus`... 都不存在)
|
||||
- ✅ 等同於 self-contained binary,搬到任一台 macOS 10.15+ x86_64 都能跑
|
||||
- ✅ 新增的皆為 Apple 第一方系統 framework,**LGPL 合規未破**,仍是 self-contained binary,搬到任一台
|
||||
macOS 10.15+ x86_64 都能跑
|
||||
|
||||
### 5. Code signing
|
||||
|
||||
@ -278,7 +313,26 @@ $ codesign -v vendor/ffmpeg/macos/ffmpeg # exit 0, no output
|
||||
$ codesign -v vendor/ffmpeg/macos/ffprobe # exit 0, no output
|
||||
```
|
||||
|
||||
ad-hoc simbol signing ok,Gatekeeper 可過。
|
||||
ad-hoc symbol signing ok,Gatekeeper 可過。
|
||||
|
||||
### 6. avfoundation indev(ADR-020,本次 rebuild 新增)
|
||||
|
||||
```
|
||||
$ vendor/ffmpeg/macos/ffmpeg -hide_banner -devices 2>&1 | grep avfoundation
|
||||
D avfoundation
|
||||
|
||||
$ vendor/ffmpeg/macos/ffmpeg -hide_banner -f avfoundation -list_devices true -i ""
|
||||
[AVFoundation indev @ ...] AVFoundation video devices:
|
||||
[AVFoundation indev @ ...] [0] FaceTime HD相機(內建)
|
||||
[AVFoundation indev @ ...] [1] Capture screen 0
|
||||
[AVFoundation indev @ ...] AVFoundation audio devices:
|
||||
[AVFoundation indev @ ...] [0] MacBook Pro的麥克風
|
||||
```
|
||||
|
||||
- ✅ `-devices` 列出 `avfoundation`(demuxing supported)
|
||||
- ✅ `-list_devices true` 成功列出實體攝影機(FaceTime HD 相機)+ 音訊裝置
|
||||
- ✅ 不再出現 `Unknown input format: 'avfoundation'`(camera 開不了的根因已解)
|
||||
- 註:`-list_devices true` 列完裝置後以非 0 退出(`Error opening input`)屬正常行為,非失敗。
|
||||
|
||||
---
|
||||
|
||||
|
||||
BIN
local-agent/vendor/ffmpeg/macos/ffmpeg
vendored
BIN
local-agent/vendor/ffmpeg/macos/ffmpeg
vendored
Binary file not shown.
BIN
local-agent/vendor/ffmpeg/macos/ffprobe
vendored
BIN
local-agent/vendor/ffmpeg/macos/ffprobe
vendored
Binary file not shown.
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)
|
||||
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 打
|
||||
// local-agent issue-token)。契約 path 為 /api/devices/:serial/local-upload-ticket,
|
||||
// 但 gin/httprouter 要求同層級同名,故沿用 :id 佔位(其值語意為裝置序號 serial,
|
||||
|
||||
@ -21,6 +21,14 @@ const (
|
||||
ErrCodeInvalidSignature = "INVALID_SIGNATURE"
|
||||
// ErrCodeConflict 對齊 HTTP 409(例:unique 約束衝突 — 同 owner+serial 重複註冊)。
|
||||
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。
|
||||
// DB 接入塊 5.4 fail-fast 策略:PG 連線失敗 / context 逾時 → 503,讓 load balancer 知道
|
||||
// 這台不健康,而非回假資料或 500(500 會誤導為「程式 bug」,503 才是「依賴暫時不可用」)。
|
||||
|
||||
@ -45,6 +45,9 @@ func registerModelRoutes(g *gin.RouterGroup, deps Deps) {
|
||||
// Phase 0.9 模型庫 model 直連 FAA 下載(ADR-017 (a))。
|
||||
g.GET("/models/:id/download", modelsDownloadHandler(deps))
|
||||
|
||||
// 模型共享(library / profile / visibility / shares)。
|
||||
registerModelSharingRoutes(g, deps)
|
||||
|
||||
// load-to-device 雛形先 stub(完整實作需要 presigned GET + 透過 tunnel 送指令給 local agent)
|
||||
g.POST("/models/:id/load-to-device", func(c *gin.Context) {
|
||||
WriteNotImplemented(c, "models.load-to-device — pending Phase 1")
|
||||
@ -67,6 +70,9 @@ type ModelResponse struct {
|
||||
InputShape []int `json:"input_shape,omitempty"`
|
||||
Classes []string `json:"classes,omitempty"`
|
||||
Framework string `json:"framework,omitempty"`
|
||||
// Visibility:模型共享功能新增。既有前端未讀此欄不受影響(加欄相容);
|
||||
// 共享 UI 讀此欄顯示公開對象 badge。既有 model 遷移後為 "private"。
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
||||
@ -89,6 +95,7 @@ func toModelResponse(m *model.Model) ModelResponse {
|
||||
InputShape: m.InputShape,
|
||||
Classes: m.Classes,
|
||||
Framework: m.Framework,
|
||||
Visibility: m.Visibility,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
UploadedAt: m.UploadedAt,
|
||||
@ -559,9 +566,11 @@ func modelsDownloadHandler(deps Deps) gin.HandlerFunc {
|
||||
WriteDBError(c, deps.Logger, "get model", err)
|
||||
return
|
||||
}
|
||||
// 第一階段 owner-only(B 分享後續階段);非 owner 回 403。
|
||||
if m.OwnerUserID != userID {
|
||||
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
|
||||
// 模型共享放寬:owner-only → 共享權限檢查(TDD §5 download 連帶變更)。
|
||||
// 與 profile 可見性共用同一 canAccessModel(single source of truth,杜絕邏輯漂移,SEC-2)。
|
||||
// 不命中回 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
|
||||
}
|
||||
|
||||
|
||||
@ -218,9 +218,12 @@ func TestModelsDownload_NotFound(t *testing.T) {
|
||||
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"}
|
||||
// 登入 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")
|
||||
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)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), ErrCodeForbidden)
|
||||
assert.Equal(t, 0, iss.calls, "should not issue token for non-owner")
|
||||
assert.Equal(t, http.StatusNotFound, w.Code, "private model 非 owner 應回 404(防 enumeration)")
|
||||
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
|
||||
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。
|
||||
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(ctx context.Context, id string) error
|
||||
}
|
||||
@ -231,6 +241,30 @@ func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error {
|
||||
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、
|
||||
// 未刪除);不存在回 ErrNotFound。in-memory 忽略 q(無交易需求)。
|
||||
//
|
||||
|
||||
@ -3,6 +3,7 @@ package device
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@ -174,3 +175,73 @@ func TestInMemoryRepository_GetRepresentativeByAgent(t *testing.T) {
|
||||
_, err = r.GetRepresentativeByAgentTx(ctx, nil, "other-agent")
|
||||
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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@ -292,6 +293,37 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device
|
||||
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、
|
||||
// 未刪除);不存在回 ErrNotFound(在傳入 Querier / tx 上執行)。
|
||||
//
|
||||
|
||||
@ -743,3 +743,108 @@ func TestPG_ContextCancel(t *testing.T) {
|
||||
_, err = r.List(ctx, owner)
|
||||
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"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@ -41,6 +43,54 @@ const (
|
||||
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)
|
||||
// ==========================================================================
|
||||
@ -79,12 +129,75 @@ type Model struct {
|
||||
Source Source `json:"source"`
|
||||
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"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
UploadedAt *time.Time `json:"uploadedAt,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
|
||||
// ==========================================================================
|
||||
@ -103,6 +216,11 @@ type Repository interface {
|
||||
// Get 取得單一 Model;不存在或已刪除回 ErrNotFound。
|
||||
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(ctx context.Context, filter ListFilter) ([]*Model, error)
|
||||
|
||||
@ -111,6 +229,29 @@ type Repository interface {
|
||||
|
||||
// Delete 軟刪除。
|
||||
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 {
|
||||
mu sync.RWMutex
|
||||
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。
|
||||
func NewInMemoryRepository() *InMemoryRepository {
|
||||
return &InMemoryRepository{
|
||||
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。
|
||||
func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error) {
|
||||
r.mu.RLock()
|
||||
@ -171,6 +338,19 @@ func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error)
|
||||
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。
|
||||
func (r *InMemoryRepository) List(ctx context.Context, filter ListFilter) ([]*Model, error) {
|
||||
r.mu.RLock()
|
||||
@ -211,6 +391,10 @@ func (r *InMemoryRepository) Save(ctx context.Context, m *Model) error {
|
||||
} else if cp.CreatedAt.IsZero() {
|
||||
cp.CreatedAt = now
|
||||
}
|
||||
// visibility 預設 private(對齊 DB DEFAULT 'private'):呼叫端未設時不會意外變公開。
|
||||
if cp.Visibility == "" {
|
||||
cp.Visibility = VisibilityPrivate
|
||||
}
|
||||
cp.UpdatedAt = now
|
||||
r.models[m.ID] = &cp
|
||||
return nil
|
||||
@ -231,5 +415,222 @@ func (r *InMemoryRepository) Delete(ctx context.Context, id string) error {
|
||||
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。
|
||||
var _ Repository = (*InMemoryRepository)(nil)
|
||||
|
||||
@ -46,7 +46,7 @@ var _ Repository = (*PostgresRepository)(nil)
|
||||
// modelColumns 是 SELECT / RETURNING 共用的欄位清單(順序必須與 scanModel 對齊)。
|
||||
const modelColumns = `id, owner_user_id, name, description, storage_key, file_size,
|
||||
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。
|
||||
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),
|
||||
// 對齊 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 = `
|
||||
INSERT INTO models (
|
||||
id, owner_user_id, name, description, storage_key, file_size,
|
||||
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 (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$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
|
||||
owner_user_id = EXCLUDED.owner_user_id,
|
||||
@ -163,6 +170,7 @@ func (r *PostgresRepository) Save(ctx context.Context, m *Model) error {
|
||||
framework = EXCLUDED.framework,
|
||||
source = EXCLUDED.source,
|
||||
source_job_id = EXCLUDED.source_job_id,
|
||||
visibility = EXCLUDED.visibility,
|
||||
-- 保留原 created_at 僅當既有列未刪除;已刪除(復活)或值不同則用新值。
|
||||
created_at = CASE
|
||||
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
|
||||
string(m.Source), // $13
|
||||
nullableUUID(m.SourceJobID), // $14
|
||||
createdAt, // $15
|
||||
m.UploadedAt, // $16
|
||||
m.DeletedAt, // $17
|
||||
visibility, // $15
|
||||
createdAt, // $16
|
||||
m.UploadedAt, // $17
|
||||
m.DeletedAt, // $18
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model: pg Save upsert: %w", err)
|
||||
@ -254,6 +263,7 @@ func scanModel(row rowScanner) (*Model, error) {
|
||||
&framework,
|
||||
&m.Source,
|
||||
&sourceJobID,
|
||||
&m.Visibility,
|
||||
&m.CreatedAt,
|
||||
&m.UpdatedAt,
|
||||
&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
|
||||
cp.UploadedAt = &t
|
||||
}
|
||||
// preset 是公用模型,語意等同全平台可見(visibility=public)。
|
||||
// 在此統一標記,preset 宣告區不必逐筆設 Visibility。
|
||||
cp.Visibility = VisibilityPublic
|
||||
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,不另建。
|
||||
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* ModelProfileClient 雙態測試
|
||||
*
|
||||
* 覆蓋:
|
||||
* - owner 版(myAccess=owner):顯示公開設定 + 刪除按鈕
|
||||
* - 公開 / 共享版(myAccess=viewer):隱藏公開設定 / 刪除;顯示 owner 資訊列
|
||||
* - canDownload → 顯示下載鈕
|
||||
* - 無權限 / 404(profileError)→ 全頁 EmptyState「找不到 / 無權限」
|
||||
*
|
||||
* 走 store mock 模式,直接以 _setProfile / setState 注入 profile,不打真實 API。
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import type { ModelProfile } from "@/lib/api/model-sharing";
|
||||
import { useModelSharingStore } from "@/stores/model-sharing-store";
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ModelProfileClient } from "./model-profile-client";
|
||||
|
||||
const ownerProfile: ModelProfile = {
|
||||
id: "p1",
|
||||
name: "我的模型",
|
||||
targetChip: "kl520",
|
||||
fileSize: 1024 * 1024,
|
||||
source: "converted",
|
||||
status: "ready",
|
||||
visibility: "private",
|
||||
owner: { id: "me", name: "我", isMe: true },
|
||||
myAccess: "owner",
|
||||
canDownload: true,
|
||||
createdAt: "2026-07-01T00:00:00Z",
|
||||
updatedAt: "2026-07-02T00:00:00Z",
|
||||
};
|
||||
|
||||
const viewerProfile: ModelProfile = {
|
||||
...ownerProfile,
|
||||
id: "p2",
|
||||
name: "共享模型",
|
||||
visibility: "public",
|
||||
owner: { id: "alice", name: "Alice", isMe: false },
|
||||
myAccess: "viewer",
|
||||
};
|
||||
|
||||
/**
|
||||
* 讓 loadProfile 直接把注入的 profile 放進 store(不打 API),避免 useEffect 覆蓋。
|
||||
*/
|
||||
function stubLoadProfile(profile: ModelProfile | null, error: string | null = null) {
|
||||
useModelSharingStore.setState({
|
||||
_mockMode: true,
|
||||
loadProfile: async () => {
|
||||
useModelSharingStore.setState({
|
||||
profile,
|
||||
profileError: error,
|
||||
isProfileLoading: false,
|
||||
});
|
||||
},
|
||||
clearProfile: () => {
|
||||
/* 測試中保留注入的 profile,不清空 */
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfile(id: string) {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<ModelProfileClient id={id} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useModelSharingStore.setState({
|
||||
profile: null,
|
||||
isProfileLoading: false,
|
||||
profileError: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// 還原 store 被 stub 的 actions
|
||||
useModelSharingStore.setState(useModelSharingStore.getInitialState?.() ?? {});
|
||||
});
|
||||
|
||||
describe("owner 版", () => {
|
||||
it("顯示公開設定 + 刪除 + 下載", async () => {
|
||||
stubLoadProfile(ownerProfile);
|
||||
renderProfile("p1");
|
||||
await waitFor(() => expect(screen.getByText("我的模型")).toBeInTheDocument());
|
||||
|
||||
expect(screen.getByTestId("profile-visibility")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("profile-download")).toBeInTheDocument();
|
||||
// 刪除鈕(common.delete)
|
||||
expect(screen.getByText("刪除")).toBeInTheDocument();
|
||||
// owner 不顯示擁有者資訊列
|
||||
expect(screen.queryByTestId("model-owner-bar")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("公開 / 共享版(非 owner)", () => {
|
||||
it("隱藏公開設定 / 刪除;顯示 owner 資訊列", async () => {
|
||||
stubLoadProfile(viewerProfile);
|
||||
renderProfile("p2");
|
||||
await waitFor(() => expect(screen.getByText("共享模型")).toBeInTheDocument());
|
||||
|
||||
expect(screen.queryByTestId("profile-visibility")).not.toBeInTheDocument();
|
||||
// 下載仍可(canDownload=true)
|
||||
expect(screen.getByTestId("profile-download")).toBeInTheDocument();
|
||||
// 擁有者資訊列
|
||||
expect(screen.getByTestId("model-owner-bar")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("model-owner-bar")).toHaveTextContent("Alice");
|
||||
});
|
||||
|
||||
it("canDownload=false → 不顯示下載鈕", async () => {
|
||||
stubLoadProfile({ ...viewerProfile, canDownload: false });
|
||||
renderProfile("p2");
|
||||
await waitFor(() => expect(screen.getByText("共享模型")).toBeInTheDocument());
|
||||
expect(screen.queryByTestId("profile-download")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("無權限 / 404", () => {
|
||||
it("profileError=not_found → 全頁 EmptyState「找不到 / 無權限」", async () => {
|
||||
stubLoadProfile(null, "not_found");
|
||||
renderProfile("nope");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("找不到模型或沒有存取權")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
337
visionA-frontend/src/app/models/[id]/model-profile-client.tsx
Normal file
337
visionA-frontend/src/app/models/[id]/model-profile-client.tsx
Normal file
@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ModelProfileClient — 模型 profile 頁(owner / 公開雙態)
|
||||
*
|
||||
* 對齊設計規格 §6 + API 契約 §2(GET /:id/profile)。取代舊的 owner-only detail:
|
||||
* 用 `GET /api/models/:id/profile` 取詳情(後端依身份裁剪 + 權限檢查),依 `myAccess`
|
||||
* 決定渲染 owner 版或公開 / 共享版。
|
||||
*
|
||||
* 雙態差異(設計規格 §6.2):
|
||||
* - owner 版(myAccess==='owner'):下載(若可)+ 刪除 + 【新增】公開設定 Dialog
|
||||
* - 公開 / 共享版:僅下載(canDownload 為 true 時);隱藏刪除 / 公開設定;顯示 ModelOwnerBar
|
||||
*
|
||||
* 錯誤(契約 §2):無可見性 → 404(防 enumeration)→ 全頁「找不到 / 無權限」EmptyState。
|
||||
*
|
||||
* 下載沿用既有 FAA delegated download(lib/api/model-download),走同一 endpoint
|
||||
* (契約 §3:download 已加共享權限檢查)。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, DownloadIcon, Globe, SearchX, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ModelOwnerBar } from "@/components/models/model-owner-bar";
|
||||
import { ModelVisibilityBadge } from "@/components/models/model-visibility-badge";
|
||||
import { ModelVisibilityDialog } from "@/components/models/model-visibility-dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
getModelDownload,
|
||||
ModelDownloadError,
|
||||
triggerNavDownload,
|
||||
} from "@/lib/api/model-download";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { useModelSharingStore } from "@/stores/model-sharing-store";
|
||||
import { useModelStore } from "@/stores/model-store";
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (!bytes) return "—";
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatInputShape(shape: number[]): string {
|
||||
return shape.join(" × ");
|
||||
}
|
||||
|
||||
const CLASSES_PREVIEW_LIMIT = 8;
|
||||
|
||||
interface ModelProfileClientProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function ModelProfileClient({ id }: ModelProfileClientProps) {
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
const profile = useModelSharingStore((s) => s.profile);
|
||||
const isLoading = useModelSharingStore((s) => s.isProfileLoading);
|
||||
const profileError = useModelSharingStore((s) => s.profileError);
|
||||
const loadProfile = useModelSharingStore((s) => s.loadProfile);
|
||||
const clearProfile = useModelSharingStore((s) => s.clearProfile);
|
||||
|
||||
// 刪除沿用既有 model-store(owner-only DELETE /api/models/:id)。
|
||||
const deleteModel = useModelStore((s) => s.deleteModel);
|
||||
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [downloadBusy, setDownloadBusy] = useState(false);
|
||||
const [visibilityDialogOpen, setVisibilityDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) void loadProfile(id);
|
||||
return () => clearProfile();
|
||||
}, [id, loadProfile, clearProfile]);
|
||||
|
||||
const isOwner = profile?.myAccess === "owner";
|
||||
|
||||
async function handleDownload() {
|
||||
if (!profile || downloadBusy) return;
|
||||
setDownloadBusy(true);
|
||||
try {
|
||||
const grant = await getModelDownload(profile.id);
|
||||
triggerNavDownload(grant.downloadUrl);
|
||||
toast.success(t("models.download.toast.start"), {
|
||||
description: t("models.download.toast.hint"),
|
||||
});
|
||||
} catch (err) {
|
||||
const code = err instanceof ModelDownloadError ? err.code : "unknown";
|
||||
const key = `models.download.error.${code}`;
|
||||
const desc = t(key);
|
||||
toast.error(t("models.download.error.title"), {
|
||||
description: desc === key ? t("models.download.error.unknown") : desc,
|
||||
});
|
||||
} finally {
|
||||
setDownloadBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setDeleting(true);
|
||||
const ok = await deleteModel(id);
|
||||
setDeleting(false);
|
||||
if (ok) {
|
||||
toast.success(t("common.save"));
|
||||
router.push("/models");
|
||||
} else {
|
||||
toast.error(t("common.error"));
|
||||
}
|
||||
}
|
||||
|
||||
const backButton = (
|
||||
<Link href="/models/library">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft aria-hidden className="mr-2 size-4" />
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
|
||||
// 載入中
|
||||
if (isLoading && !profile) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-4 px-6 py-8">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
<Skeleton className="h-48 rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 無權限 / 找不到(契約:無可見性回 404,合併「找不到 / 無權限」)。
|
||||
if (profileError || !profile) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-4 px-6 py-8">
|
||||
{backButton}
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title={t("models.profile.notFound.title")}
|
||||
description={t("models.profile.notFound.description")}
|
||||
action={{
|
||||
label: t("models.profile.backToLibrary"),
|
||||
onClick: () => router.push("/models/library"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 px-6 py-8">
|
||||
{backButton}
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold">{profile.name}</h1>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{profile.targetChip.toUpperCase()}</Badge>
|
||||
<Badge variant={profile.status === "ready" ? "default" : "secondary"}>
|
||||
{t(`models.status.${profile.status === "ready" ? "ready" : "scanning"}`)}
|
||||
</Badge>
|
||||
{profile.source !== "uploaded" && (
|
||||
<Badge variant="secondary">{t(`models.source.${profile.source}`)}</Badge>
|
||||
)}
|
||||
{/* owner 看到自己的 visibility;非 owner 看到共享標示。 */}
|
||||
<ModelVisibilityBadge
|
||||
visibility={profile.visibility}
|
||||
sharedWithMe={!isOwner}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{profile.canDownload && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={downloadBusy}
|
||||
aria-label={t("models.action.download.aria")}
|
||||
data-testid="profile-download"
|
||||
>
|
||||
{downloadBusy ? (
|
||||
<>
|
||||
<Spinner size="sm" label={t("models.action.downloading")} />
|
||||
{t("models.action.downloading")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DownloadIcon aria-hidden className="mr-2 size-4" />
|
||||
{t("models.action.download")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* owner-only 操作:公開設定 + 刪除 */}
|
||||
{isOwner && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setVisibilityDialogOpen(true)}
|
||||
data-testid="profile-visibility"
|
||||
>
|
||||
<Globe aria-hidden className="mr-2 size-4" />
|
||||
{t("models.visibility.title")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={deleting}>
|
||||
<Trash2 aria-hidden className="mr-2 size-4" />
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("common.confirm")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{profile.name}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 非 owner:擁有者資訊列。 */}
|
||||
{!isOwner && (
|
||||
<ModelOwnerBar ownerName={profile.owner.name} sharedAt={profile.updatedAt} />
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("models.detail.description")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{profile.description ? (
|
||||
<p className="text-sm">{profile.description}</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">—</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3 pt-3 text-sm">
|
||||
<InfoRow label={t("models.size")} value={formatFileSize(profile.fileSize)} />
|
||||
<InfoRow
|
||||
label={t("models.createdAt")}
|
||||
value={profile.createdAt ? new Date(profile.createdAt).toLocaleString() : "—"}
|
||||
/>
|
||||
{profile.framework && (
|
||||
<InfoRow
|
||||
label={t("models.detail.framework")}
|
||||
value={<span className="font-mono text-xs">{profile.framework}</span>}
|
||||
/>
|
||||
)}
|
||||
{profile.inputShape && profile.inputShape.length > 0 && (
|
||||
<InfoRow
|
||||
label={t("models.detail.inputShape")}
|
||||
value={
|
||||
<span className="font-mono text-xs">
|
||||
{formatInputShape(profile.inputShape)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{profile.classes && profile.classes.length > 0 && (
|
||||
<div className="space-y-2 border-t pt-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground">{t("models.detail.classes")}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{profile.classes.length} {t("models.detail.classesCountSuffix")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{profile.classes.slice(0, CLASSES_PREVIEW_LIMIT).map((c, i) => (
|
||||
<Badge key={`${i}-${c}`} variant="secondary" className="font-normal">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
{profile.classes.length > CLASSES_PREVIEW_LIMIT && (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
+{profile.classes.length - CLASSES_PREVIEW_LIMIT}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isOwner && (
|
||||
<ModelVisibilityDialog
|
||||
modelId={profile.id}
|
||||
modelName={profile.name}
|
||||
currentVisibility={profile.visibility}
|
||||
open={visibilityDialogOpen}
|
||||
onOpenChange={setVisibilityDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="text-right">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,16 @@
|
||||
import { ModelDetailClient } from "./model-detail-client";
|
||||
import { ModelProfileClient } from "./model-profile-client";
|
||||
|
||||
/**
|
||||
* 模型 profile 頁 — /models/[id]
|
||||
*
|
||||
* 模型共享功能後改用 ModelProfileClient(owner / 公開雙態,走 GET /:id/profile,
|
||||
* 支援非 owner 依權限檢視)。舊的 owner-only ModelDetailClient 保留於同目錄但不再掛路由。
|
||||
*/
|
||||
export default async function ModelDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return <ModelDetailClient id={id} />;
|
||||
return <ModelProfileClient id={id} />;
|
||||
}
|
||||
|
||||
129
visionA-frontend/src/app/models/library/library-client.test.tsx
Normal file
129
visionA-frontend/src/app/models/library/library-client.test.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
/**
|
||||
* LibraryClient 測試(共享模型庫 + cursor 無限捲動)
|
||||
*
|
||||
* 覆蓋:
|
||||
* - 首屏載入 → skeleton
|
||||
* - 載入完成 → 卡片網格 + 哨兵(hasMore)
|
||||
* - 觸發哨兵(模擬 IntersectionObserver)→ loadMore append
|
||||
* - 空狀態(無資料)
|
||||
* - 搜尋無結果空狀態
|
||||
*
|
||||
* IntersectionObserver 在 jsdom 需 mock:這裡用可手動觸發的假 observer,
|
||||
* 讓測試主動「讓哨兵進入視窗」以驗證 loadMore。走 store mock 模式(fixtures)。
|
||||
*/
|
||||
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
DEFAULT_LIBRARY_FILTERS,
|
||||
useModelSharingStore,
|
||||
} from "@/stores/model-sharing-store";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
// 可手動觸發的假 IntersectionObserver。
|
||||
let intersectCallbacks: IntersectionObserverCallback[] = [];
|
||||
class FakeIntersectionObserver {
|
||||
constructor(cb: IntersectionObserverCallback) {
|
||||
intersectCallbacks.push(cb);
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return [];
|
||||
}
|
||||
root = null;
|
||||
rootMargin = "";
|
||||
thresholds = [];
|
||||
}
|
||||
|
||||
function triggerIntersect() {
|
||||
for (const cb of intersectCallbacks) {
|
||||
cb(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
import { LibraryClient } from "./library-client";
|
||||
|
||||
function resetStore() {
|
||||
useModelSharingStore.setState({
|
||||
items: [],
|
||||
filters: { ...DEFAULT_LIBRARY_FILTERS },
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
listError: null,
|
||||
_mockMode: true,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
intersectCallbacks = [];
|
||||
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
|
||||
resetStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderLibrary() {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<LibraryClient />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("首屏載入", () => {
|
||||
it("載入完成 → 顯示卡片網格 + 哨兵(hasMore)", async () => {
|
||||
renderLibrary();
|
||||
// mock loadFirstPage 是 async;等網格出現
|
||||
await waitFor(() => expect(screen.getByTestId("library-grid")).toBeInTheDocument());
|
||||
// 首頁 24 筆 → hasMore=true → 哨兵存在
|
||||
expect(screen.getByTestId("library-sentinel")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cursor 無限捲動", () => {
|
||||
it("哨兵進入視窗 → loadMore append(總數增加)", async () => {
|
||||
renderLibrary();
|
||||
await waitFor(() => expect(screen.getByTestId("library-grid")).toBeInTheDocument());
|
||||
|
||||
const before = useModelSharingStore.getState().items.length;
|
||||
expect(before).toBe(24);
|
||||
|
||||
// 模擬捲到底:哨兵進入視窗
|
||||
await act(async () => {
|
||||
triggerIntersect();
|
||||
// 等 store loadMore 完成
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
const after = useModelSharingStore.getState().items.length;
|
||||
expect(after).toBeGreaterThan(before);
|
||||
expect(after).toBe(30); // fixtures 共 30 筆,第二頁補齊
|
||||
});
|
||||
});
|
||||
|
||||
describe("空狀態", () => {
|
||||
it("搜尋無結果 → 顯示搜尋空狀態", async () => {
|
||||
useModelSharingStore.setState({
|
||||
filters: { ...DEFAULT_LIBRARY_FILTERS, q: "zzz-no-such" },
|
||||
});
|
||||
renderLibrary();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("找不到符合條件的模型")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
213
visionA-frontend/src/app/models/library/library-client.tsx
Normal file
213
visionA-frontend/src/app/models/library/library-client.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* LibraryClient — 共享模型庫列表頁(cursor 無限捲動)
|
||||
*
|
||||
* 對齊設計規格 §4 + API 契約 §1。使用者拍板「cursor 無限捲動」(往下捲自動載入更多)。
|
||||
*
|
||||
* 狀態機(設計規格 §7):
|
||||
* - 首屏載入 → skeleton 網格
|
||||
* - 有資料 → 卡片網格 + 底部哨兵(IntersectionObserver 觸發 loadMore)
|
||||
* - 續載中 → 底部補 skeleton 卡片
|
||||
* - 空(無共享模型) → EmptyState
|
||||
* - 搜尋無結果 → EmptyState + 清除搜尋 CTA
|
||||
* - 列表錯誤 → 錯誤提示 + 重試
|
||||
*
|
||||
* 搜尋 debounce 300ms(設計規格 §4.4):local searchInput → debounce → store.setFilters({ q })。
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Boxes, SearchX, Users } from "lucide-react";
|
||||
|
||||
import { LibraryModelCard } from "@/components/models/library-model-card";
|
||||
import { LibraryToolbar } from "@/components/models/library-toolbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { useModelSharingStore } from "@/stores/model-sharing-store";
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
function SkeletonGrid({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
data-testid="library-skeleton"
|
||||
>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-52 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LibraryClient() {
|
||||
const t = useT();
|
||||
|
||||
const items = useModelSharingStore((s) => s.items);
|
||||
const filters = useModelSharingStore((s) => s.filters);
|
||||
const hasMore = useModelSharingStore((s) => s.hasMore);
|
||||
const isLoading = useModelSharingStore((s) => s.isLoading);
|
||||
const isLoadingMore = useModelSharingStore((s) => s.isLoadingMore);
|
||||
const listError = useModelSharingStore((s) => s.listError);
|
||||
const loadFirstPage = useModelSharingStore((s) => s.loadFirstPage);
|
||||
const loadMore = useModelSharingStore((s) => s.loadMore);
|
||||
const setFilters = useModelSharingStore((s) => s.setFilters);
|
||||
|
||||
// 搜尋框 local state(受控),debounce 後才推進 store filters。
|
||||
const [searchInput, setSearchInput] = useState(filters.q);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 首次掛載載入首頁。
|
||||
useEffect(() => {
|
||||
void loadFirstPage();
|
||||
}, [loadFirstPage]);
|
||||
|
||||
// 搜尋 debounce → setFilters(會重置分頁重載)。
|
||||
useEffect(() => {
|
||||
if (searchInput === filters.q) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setFilters({ q: searchInput });
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [searchInput, filters.q, setFilters]);
|
||||
|
||||
const { sentinelRef } = useInfiniteScroll({
|
||||
enabled: hasMore && !isLoading && !isLoadingMore,
|
||||
onLoadMore: loadMore,
|
||||
});
|
||||
|
||||
const isEmpty = !isLoading && items.length === 0;
|
||||
const isSearchActive =
|
||||
filters.q.trim() !== "" ||
|
||||
filters.targetChip !== "all" ||
|
||||
filters.visibility !== "all" ||
|
||||
filters.owned !== "all";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6 px-6 py-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t("models.library.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("models.library.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<LibraryToolbar
|
||||
filters={filters}
|
||||
searchInput={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
onFilterChange={setFilters}
|
||||
/>
|
||||
|
||||
{/* 搜尋結果數(無障礙播報)。 */}
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{t("models.library.resultCount").replace("{n}", String(items.length))}
|
||||
</p>
|
||||
|
||||
{/* 首屏載入 */}
|
||||
{isLoading && <SkeletonGrid />}
|
||||
|
||||
{/* 列表錯誤(首屏) */}
|
||||
{!isLoading && listError && items.length === 0 && (
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title={t("models.library.error.title")}
|
||||
description={t("models.library.error.description")}
|
||||
action={{
|
||||
label: t("common.retry"),
|
||||
onClick: () => void loadFirstPage(),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 空狀態 */}
|
||||
{isEmpty && !listError && (
|
||||
isSearchActive ? (
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title={t("models.library.empty.search.title")}
|
||||
description={t("models.library.empty.search.description")}
|
||||
action={{
|
||||
label: t("models.search.clearAll"),
|
||||
onClick: () => {
|
||||
setSearchInput("");
|
||||
setFilters({
|
||||
q: "",
|
||||
targetChip: "all",
|
||||
visibility: "all",
|
||||
owned: "all",
|
||||
});
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={t("models.library.empty.title")}
|
||||
description={t("models.library.empty.description")}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 卡片網格 */}
|
||||
{!isLoading && items.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
data-testid="library-grid"
|
||||
>
|
||||
{items.map((model) => (
|
||||
<LibraryModelCard key={model.id} model={model} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 續載中 skeleton */}
|
||||
{isLoadingMore && (
|
||||
<div className="mt-4">
|
||||
<SkeletonGrid count={4} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 續載錯誤(已有資料時)→ 重試按鈕 */}
|
||||
{listError && !isLoadingMore && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void loadMore()}
|
||||
data-testid="library-load-more-retry"
|
||||
>
|
||||
{t("models.library.loadMore.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 無限捲動哨兵(有下一頁且無錯誤時掛載) */}
|
||||
{hasMore && !listError && (
|
||||
<div
|
||||
ref={sentinelRef}
|
||||
className="h-4"
|
||||
aria-hidden
|
||||
data-testid="library-sentinel"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 到底提示 */}
|
||||
{!hasMore && (
|
||||
<p
|
||||
className="text-muted-foreground flex items-center justify-center gap-2 py-4 text-sm"
|
||||
data-testid="library-end"
|
||||
>
|
||||
<Boxes aria-hidden className="size-4" />
|
||||
{t("models.library.end")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
visionA-frontend/src/app/models/library/page.tsx
Normal file
11
visionA-frontend/src/app/models/library/page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { LibraryClient } from "./library-client";
|
||||
|
||||
/**
|
||||
* 共享模型庫 — /models/library
|
||||
*
|
||||
* 依身份權限可見的模型列表(我的 ∪ 公開 ∪ 同租戶 ∪ 分享給我 ∪ preset),
|
||||
* cursor 無限捲動分頁。對齊 api-model-sharing.md §1、feature-model-sharing-design.md §4。
|
||||
*/
|
||||
export default function ModelLibraryPage() {
|
||||
return <LibraryClient />;
|
||||
}
|
||||
@ -12,6 +12,8 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Users } from "lucide-react";
|
||||
|
||||
import {
|
||||
ModelFilters,
|
||||
@ -19,6 +21,7 @@ import {
|
||||
} from "@/components/models/model-filters";
|
||||
import { ModelSection } from "@/components/models/model-section";
|
||||
import { ModelUploadDialog } from "@/components/models/model-upload-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import {
|
||||
type ModelSource,
|
||||
@ -83,8 +86,16 @@ export default function ModelsPage() {
|
||||
<h1 className="text-2xl font-bold">{t("models.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("models.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/models/library">
|
||||
<Button variant="outline" data-testid="models-library-link">
|
||||
<Users aria-hidden className="mr-2 size-4" />
|
||||
{t("models.library.link")}
|
||||
</Button>
|
||||
</Link>
|
||||
<ModelUploadDialog />
|
||||
</div>
|
||||
</div>
|
||||
<ModelFilters value={filter} onChange={setFilter} />
|
||||
<div className="space-y-8">
|
||||
{SECTION_ORDER.map((source) => (
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AlertCircle, CheckCircle2, Circle, Loader2 } from "lucide-react";
|
||||
|
||||
import { formatRelativeTime } from "@/lib/format/relative-time";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RemoteStatus } from "@/stores/device-store";
|
||||
@ -36,30 +37,6 @@ export interface RemoteDeviceBadgeProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化相對時間(components.md §10.3 規格)。
|
||||
* - < 60 秒 → 「剛剛」
|
||||
* - < 60 分 → 「X 分鐘前」
|
||||
* - < 24 時 → 「X 小時前」
|
||||
* - ≥ 24 時 → 絕對時間「MM/DD HH:mm」
|
||||
*/
|
||||
function formatRelativeTime(isoString: string, nowMs: number, t: (k: string) => string): string {
|
||||
const ts = Date.parse(isoString);
|
||||
if (Number.isNaN(ts)) return "";
|
||||
const diffSec = Math.max(0, Math.floor((nowMs - ts) / 1000));
|
||||
if (diffSec < 60) return t("remote.lastSeen.justNow");
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return t("remote.lastSeen.minutesAgo").replace("{n}", String(diffMin));
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return t("remote.lastSeen.hoursAgo").replace("{n}", String(diffHour));
|
||||
const d = new Date(ts);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
|
||||
export function RemoteDeviceBadge({
|
||||
status,
|
||||
lastSeenAt,
|
||||
|
||||
@ -63,3 +63,52 @@ describe("DeviceCard — serial 路由 gating(WP-C / ADR-018)", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DeviceCard — 三態分色 + 註冊 UI(TDD §5)", () => {
|
||||
it("已連接未註冊(online + registeredAt null)→ warning 標記(文字「未註冊」+ icon,不只靠色)", () => {
|
||||
renderCard({ ...baseDevice, remoteStatus: "online", registeredAt: null });
|
||||
const badge = screen.getByTestId("unregistered-badge");
|
||||
// 不只靠顏色:badge 有文字「未註冊」
|
||||
expect(badge).toHaveTextContent("未註冊");
|
||||
// 卡片 data-tri-state 標記便於測試/樣式
|
||||
expect(screen.getByTestId("device-card")).toHaveAttribute(
|
||||
"data-tri-state",
|
||||
"online-unregistered",
|
||||
);
|
||||
// 顯示「註冊」動作
|
||||
expect(screen.getByTestId("device-register-btn")).toBeInTheDocument();
|
||||
// 不顯示「取消註冊」
|
||||
expect(screen.queryByTestId("device-unregister-btn")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("已連接已註冊 → 無未註冊標記,顯示「取消註冊」動作", () => {
|
||||
renderCard({
|
||||
...baseDevice,
|
||||
remoteStatus: "online",
|
||||
registeredAt: "2026-08-02T10:00:00Z",
|
||||
});
|
||||
expect(screen.queryByTestId("unregistered-badge")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("device-card")).toHaveAttribute(
|
||||
"data-tri-state",
|
||||
"online-registered",
|
||||
);
|
||||
expect(screen.getByTestId("device-unregister-btn")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("device-register-btn")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("離線未註冊 → 無未註冊標記、無註冊動作(需先連線)", () => {
|
||||
renderCard({ ...baseDevice, remoteStatus: "offline", registeredAt: null });
|
||||
expect(screen.queryByTestId("unregistered-badge")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("device-register-btn")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("device-unregister-btn")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("離線已註冊 → 顯示「取消註冊」(已註冊不論在線與否都可取消)", () => {
|
||||
renderCard({
|
||||
...baseDevice,
|
||||
remoteStatus: "offline",
|
||||
registeredAt: "2026-08-02T10:00:00Z",
|
||||
});
|
||||
expect(screen.getByTestId("device-unregister-btn")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -23,6 +23,8 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
|
||||
import { DeviceRegisterActions } from "@/components/devices/device-register-actions";
|
||||
import { UnregisteredBadge } from "@/components/devices/unregistered-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
@ -30,6 +32,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { deriveTriState } from "@/lib/device-state";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DeviceSummary } from "@/stores/device-store";
|
||||
@ -44,15 +47,22 @@ export function DeviceCard({ device }: DeviceCardProps) {
|
||||
const isOnline = device.remoteStatus === "online";
|
||||
// WP-C(ADR-018):serial 為空 → 工作區(推論類操作)無法路由,入口 disable。
|
||||
const hasSerial = !!device.serialNumber;
|
||||
// 三態(TDD §5.2):online-unregistered = 已連接未註冊(第三態,走 warning 色)。
|
||||
const triState = deriveTriState(device);
|
||||
const isOnlineUnregistered = triState === "online-unregistered";
|
||||
const isRegistered = !!device.registeredAt;
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-testid="device-card"
|
||||
data-remote-status={device.remoteStatus}
|
||||
data-tri-state={triState}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
// 離線裝置 opacity-75(flow-offline-handling §4.1)
|
||||
!isOnline && device.remoteStatus !== "reconnecting" && "opacity-75",
|
||||
// 第三態「已連接未註冊」:warning 色邊框(配合角落 UnregisteredBadge 的文字+icon,不只靠色)。
|
||||
isOnlineUnregistered && "border-warning",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
@ -63,11 +73,15 @@ export function DeviceCard({ device }: DeviceCardProps) {
|
||||
<p className="text-muted-foreground truncate text-xs">{device.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
<RemoteDeviceBadge
|
||||
status={device.remoteStatus}
|
||||
lastSeenAt={device.lastSeenAt ?? null}
|
||||
size="sm"
|
||||
/>
|
||||
{/* 第三態標記:連線與註冊是正交兩軸,未註冊用獨立 warning pill 疊加(不塞進連線 badge)。 */}
|
||||
{isOnlineUnregistered && <UnregisteredBadge size="sm" />}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
@ -93,6 +107,11 @@ export function DeviceCard({ device }: DeviceCardProps) {
|
||||
{t("common.manage")}
|
||||
</Button>
|
||||
</Link>
|
||||
{/* 註冊 / 取消註冊:已連接未註冊 → 「註冊」;已註冊 → 「取消註冊」。
|
||||
offline 未註冊不顯示(無從註冊,需先連線)。 */}
|
||||
{(isOnlineUnregistered || isRegistered) && (
|
||||
<DeviceRegisterActions device={device} size="sm" />
|
||||
)}
|
||||
{isOnline && device.flashedModel && hasSerial && (
|
||||
<Link href={`/workspace/${device.id}`}>
|
||||
<Button size="sm">{t("devices.openWorkspace")}</Button>
|
||||
|
||||
111
visionA-frontend/src/components/devices/device-list-controls.tsx
Normal file
111
visionA-frontend/src/components/devices/device-list-controls.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DeviceListControls — 裝置列表的排序 + filter 控制列
|
||||
*
|
||||
* 規格來源:
|
||||
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §6(排序 + filter)
|
||||
*
|
||||
* 設計:
|
||||
* - 排序:Select(狀態 / 名稱 / 註冊時間),預設「狀態」(保留既有在線優先行為)。
|
||||
* - filter:三態 chips(全部 / 已連接 / 已連接未註冊 / 未連接),aria-pressed 表達選取。
|
||||
* - 純受控元件:state 由呼叫端持有(DeviceList),本元件只負責呈現 + 觸發變更。
|
||||
*/
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { DeviceFilterKey, DeviceSortKey } from "@/lib/device-state";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DeviceListControlsProps {
|
||||
sortKey: DeviceSortKey;
|
||||
filter: DeviceFilterKey;
|
||||
onSortChange: (key: DeviceSortKey) => void;
|
||||
onFilterChange: (filter: DeviceFilterKey) => void;
|
||||
}
|
||||
|
||||
const FILTERS: { key: DeviceFilterKey; labelKey: string }[] = [
|
||||
{ key: "all", labelKey: "devices.filter.all" },
|
||||
{ key: "online-registered", labelKey: "devices.filter.onlineRegistered" },
|
||||
{ key: "online-unregistered", labelKey: "devices.filter.onlineUnregistered" },
|
||||
{ key: "offline", labelKey: "devices.filter.offline" },
|
||||
];
|
||||
|
||||
const SORTS: { key: DeviceSortKey; labelKey: string }[] = [
|
||||
{ key: "status", labelKey: "devices.sort.status" },
|
||||
{ key: "name", labelKey: "devices.sort.name" },
|
||||
{ key: "registeredAt", labelKey: "devices.sort.registeredAt" },
|
||||
];
|
||||
|
||||
export function DeviceListControls({
|
||||
sortKey,
|
||||
filter,
|
||||
onSortChange,
|
||||
onFilterChange,
|
||||
}: DeviceListControlsProps) {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center justify-between gap-3"
|
||||
data-testid="device-list-controls"
|
||||
>
|
||||
{/* Filter chips — role="group" + aria-pressed(不只靠色,選取態有邊框/底色雙變化)。 */}
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("devices.filter.label")}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
{FILTERS.map(({ key, labelKey }) => {
|
||||
const active = filter === key;
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={active ? "default" : "outline"}
|
||||
aria-pressed={active}
|
||||
onClick={() => onFilterChange(key)}
|
||||
data-testid={`device-filter-${key}`}
|
||||
className={cn(active && "font-semibold")}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Sort — Select */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">{t("devices.sort.label")}</span>
|
||||
<Select
|
||||
value={sortKey}
|
||||
onValueChange={(v) => onSortChange(v as DeviceSortKey)}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-[10rem]"
|
||||
data-testid="device-sort-select"
|
||||
aria-label={t("devices.sort.label")}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORTS.map(({ key, labelKey }) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{t(labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,45 +1,56 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DeviceList — 裝置卡片網格 + 空狀態 + skeleton
|
||||
* DeviceList — 裝置卡片網格 + 空狀態 + skeleton + 排序/filter 控制
|
||||
*
|
||||
* 來源:`local-tool/frontend/src/components/devices/device-list.tsx`(雲端版改造)
|
||||
*
|
||||
* 對齊:
|
||||
* - `.autoflow/03-design/pages.md` §5.3(空狀態)
|
||||
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §6(排序 + filter)
|
||||
*
|
||||
* 改動:
|
||||
* - 空狀態導向 `/devices/pair`(F7 的 Pairing 頁),不再是 scan
|
||||
* - 排序:在線優先(online → reconnecting → unknown → offline → error)
|
||||
* - 排序:改為可自選(狀態 / 名稱 / 註冊時間),預設「狀態」保留既有在線優先行為
|
||||
* - 新增三態 filter(全部 / 已連接 / 已連接未註冊 / 未連接)+ filter 後空結果狀態
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Link2 } from "lucide-react";
|
||||
import { Link2, SearchX } from "lucide-react";
|
||||
|
||||
import { DeviceCard } from "@/components/devices/device-card";
|
||||
import { DeviceListControls } from "@/components/devices/device-list-controls";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
applyDeviceListView,
|
||||
type DeviceFilterKey,
|
||||
type DeviceSortKey,
|
||||
} from "@/lib/device-state";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import type { DeviceSummary, RemoteStatus } from "@/stores/device-store";
|
||||
import type { DeviceSummary } from "@/stores/device-store";
|
||||
|
||||
interface DeviceListProps {
|
||||
devices: DeviceSummary[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_ORDER: Record<RemoteStatus, number> = {
|
||||
online: 0,
|
||||
reconnecting: 1,
|
||||
unknown: 2,
|
||||
offline: 3,
|
||||
error: 4,
|
||||
};
|
||||
|
||||
export function DeviceList({ devices, loading }: DeviceListProps) {
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
// 排序 / filter 狀態存 local state(P0 不持久化,TDD §6.2)。
|
||||
const [sortKey, setSortKey] = useState<DeviceSortKey>("status");
|
||||
const [filter, setFilter] = useState<DeviceFilterKey>("all");
|
||||
|
||||
// 先 filter 再 sort(TDD §6.3);devices / 條件變動才重算。
|
||||
const visible = useMemo(
|
||||
() => applyDeviceListView(devices, filter, sortKey),
|
||||
[devices, filter, sortKey],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
@ -53,6 +64,7 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// 完全沒有裝置(非 filter 造成)→ 導向配對的既有空狀態。
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
@ -74,16 +86,34 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const sorted = [...devices].sort(
|
||||
(a, b) => STATUS_ORDER[a.remoteStatus] - STATUS_ORDER[b.remoteStatus],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="device-list-container">
|
||||
<DeviceListControls
|
||||
sortKey={sortKey}
|
||||
filter={filter}
|
||||
onSortChange={setSortKey}
|
||||
onFilterChange={setFilter}
|
||||
/>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
// filter 後 0 筆 → 與「完全沒裝置」區隔的空結果狀態(可清除 filter)。
|
||||
<div data-testid="device-filter-empty">
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title={t("devices.filter.empty.title")}
|
||||
description={t("devices.filter.empty.description")}
|
||||
action={{
|
||||
label: t("devices.filter.empty.action"),
|
||||
onClick: () => setFilter("all"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
data-testid="device-list"
|
||||
>
|
||||
{sorted.map((device) => (
|
||||
{visible.map((device) => (
|
||||
<DeviceCard key={device.id} device={device} />
|
||||
))}
|
||||
{/* 附一個 CTA 讓使用者能配對更多裝置,避免空間死角 */}
|
||||
@ -98,5 +128,7 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DeviceRegisterActions — 註冊 / 取消註冊動作按鈕
|
||||
*
|
||||
* 規格來源:
|
||||
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5(三態 + 註冊 UI)
|
||||
* - `docs/autoflow/04-architecture/api/api-device-mgmt.md`(register / unregister 契約)
|
||||
*
|
||||
* 行為:
|
||||
* - 「已連接未註冊」(online-unregistered)→ 顯示「註冊」按鈕(primary)。
|
||||
* - 「已註冊」(registeredAt != null,不論在線與否)→ 顯示「取消註冊」按鈕(outline)。
|
||||
* ⚠️ 取消註冊 ≠ 移除裝置(unpair):unregister 只退回未註冊態、保留裝置列,
|
||||
* 文案明確用「取消註冊」而非「移除」,避免使用者誤以為會刪掉裝置。
|
||||
* - 未連接且未註冊(offline + null)→ 無動作(不顯示按鈕)。
|
||||
*
|
||||
* 錯誤處理:
|
||||
* - register 409 ALREADY_REGISTERED → 提示「已註冊」(後端與前端可能競態)。
|
||||
* - representative REPRESENTATIVE_DEVICE / 403 FORBIDDEN → 對應 i18n,退化到 unknown 文案。
|
||||
*
|
||||
* 呼叫端(DeviceCard)已用 deriveTriState / registeredAt 決定是否 render 本元件。
|
||||
*/
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { UserCheck, UserX } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import type { DeviceSummary } from "@/stores/device-store";
|
||||
import { useDeviceStore } from "@/stores/device-store";
|
||||
|
||||
interface DeviceRegisterActionsProps {
|
||||
device: DeviceSummary;
|
||||
/** 動作成功後的回呼(例如刷新詳情頁);卡片就地更新則可不傳。 */
|
||||
onDone?: () => void | Promise<void>;
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
|
||||
/** register 失敗時把 backend code 映射到 i18n key(找不到 → unknown 文案)。 */
|
||||
function registerErrorDesc(t: (k: string) => string, code: string): string {
|
||||
const key = `devices.register.error.${code}`;
|
||||
const resolved = t(key);
|
||||
return resolved === key ? t("devices.register.error.unknown") : resolved;
|
||||
}
|
||||
|
||||
export function DeviceRegisterActions({
|
||||
device,
|
||||
onDone,
|
||||
size = "sm",
|
||||
}: DeviceRegisterActionsProps) {
|
||||
const t = useT();
|
||||
const registerDevice = useDeviceStore((s) => s.registerDevice);
|
||||
const unregisterDevice = useDeviceStore((s) => s.unregisterDevice);
|
||||
// registeringId 同時涵蓋 register / unregister 進行中;用當前 device.id 比對。
|
||||
const isPending = useDeviceStore((s) => s.registeringId === device.id);
|
||||
|
||||
const isRegistered = !!device.registeredAt;
|
||||
|
||||
async function handleRegister() {
|
||||
const result = await registerDevice(device.id);
|
||||
if (result.ok) {
|
||||
toast.success(t("devices.register.toast.success"));
|
||||
await onDone?.();
|
||||
} else {
|
||||
toast.error(t("devices.register.error.title"), {
|
||||
description: registerErrorDesc(t, result.code),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnregister() {
|
||||
const result = await unregisterDevice(device.id);
|
||||
if (result.ok) {
|
||||
toast.success(t("devices.unregister.toast.success"));
|
||||
await onDone?.();
|
||||
} else {
|
||||
toast.error(t("devices.unregister.error.title"), {
|
||||
description: registerErrorDesc(t, result.code),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isRegistered) {
|
||||
return (
|
||||
<Button
|
||||
size={size}
|
||||
variant="outline"
|
||||
onClick={handleUnregister}
|
||||
disabled={isPending}
|
||||
data-testid="device-unregister-btn"
|
||||
>
|
||||
<UserX aria-hidden="true" className="mr-1.5 size-4" />
|
||||
{isPending ? t("devices.unregister.pending") : t("devices.unregister.action")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// 未註冊:只有「已連接未註冊」才可註冊(offline 未註冊不顯示 → 由呼叫端 gate)。
|
||||
return (
|
||||
<Button
|
||||
size={size}
|
||||
onClick={handleRegister}
|
||||
disabled={isPending}
|
||||
data-testid="device-register-btn"
|
||||
>
|
||||
<UserCheck aria-hidden="true" className="mr-1.5 size-4" />
|
||||
{isPending ? t("devices.register.pending") : t("devices.register.action")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* UnregisteredBadge — 「已連接未註冊」第三態標記
|
||||
*
|
||||
* 規格來源:
|
||||
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5.3(配色落地)
|
||||
*
|
||||
* 設計要點:
|
||||
* - 連線狀態(RemoteDeviceBadge)與註冊狀態是正交兩軸,硬塞進同一個 badge 會讓
|
||||
* 「online 但未註冊」的顏色語意打架 → 用獨立的 warning 色 pill 疊加表達「未註冊」。
|
||||
* - 配色只用既有 warning design token(--warning / --warning-foreground / --warning-subtle),
|
||||
* 禁止裸色(bg-yellow-*)——對齊 pairing / login / flash-dialog 的既有慣例。
|
||||
* - 無障礙(design-review M2):不只靠顏色,帶 icon(TriangleAlert)+ 文字「未註冊」。
|
||||
*/
|
||||
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface UnregisteredBadgeProps {
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UnregisteredBadge({ size = "sm", className }: UnregisteredBadgeProps) {
|
||||
const t = useT();
|
||||
const label = t("devices.state.unregistered");
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid="unregistered-badge"
|
||||
// role/aria-label:讓 SR 讀出「未註冊」而非只感知一個色塊。
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"bg-warning-subtle text-warning-foreground border-warning inline-flex items-center gap-1 rounded-full border font-medium",
|
||||
size === "sm" ? "px-2 py-0.5 text-xs" : "px-2.5 py-1 text-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<TriangleAlert
|
||||
aria-hidden="true"
|
||||
className={cn("text-warning shrink-0", size === "sm" ? "size-3" : "size-3.5")}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
154
visionA-frontend/src/components/models/library-model-card.tsx
Normal file
154
visionA-frontend/src/components/models/library-model-card.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* LibraryModelCard — 共享模型庫卡片
|
||||
*
|
||||
* 對齊設計規格 §4.2。基於 `LibraryModel`(共享庫 DTO,含 visibility / owner / sharedWithMe /
|
||||
* myAccess),沿用既有 ModelCard 的視覺語彙(Card + Badge 列 + metadata grid)。
|
||||
*
|
||||
* 與既有 ModelCard 差異:
|
||||
* - 新增 visibility badge(三態 + sharedWithMe,見 ModelVisibilityBadge)
|
||||
* - owner 卡片(owner.isMe):右上角 ⋮ 選單 → 公開設定(開 ModelVisibilityDialog)
|
||||
* - receiver 卡片:次要資訊列「由 {ownerName} 共享」(契約不揭露 email,故用 owner.name)
|
||||
* - 整張卡片是 <Link> 到 /models/{id}(profile 頁)
|
||||
*
|
||||
* ⚠️ 契約 §4:response 不含 owner email,故 receiver 資訊列用 owner.name(非 email)。
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { MoreVertical, Settings2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { ModelVisibilityBadge } from "@/components/models/model-visibility-badge";
|
||||
import { ModelVisibilityDialog } from "@/components/models/model-visibility-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { LibraryModel } from "@/lib/api/model-sharing";
|
||||
import { formatRelativeTime } from "@/lib/format/relative-time";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
|
||||
interface LibraryModelCardProps {
|
||||
model: LibraryModel;
|
||||
/** deterministic 相對時間用(測試傳入固定值);預設 Date.now()。 */
|
||||
nowMs?: number;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function LibraryModelCard({ model, nowMs }: LibraryModelCardProps) {
|
||||
const t = useT();
|
||||
const [visibilityDialogOpen, setVisibilityDialogOpen] = useState(false);
|
||||
// 掛載時固定一次「現在」,避免 render 期呼叫 impure Date.now()。
|
||||
const [mountedNow] = useState(() => Date.now());
|
||||
const isOwner = model.owner.isMe;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="hover:bg-accent/40 relative h-full transition-shadow hover:shadow-md">
|
||||
{/* owner ⋮ 選單(絕對定位右上,避免與 <Link> 導航衝突)。 */}
|
||||
{isOwner && (
|
||||
<div className="absolute right-2 top-2 z-10">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9"
|
||||
aria-label={t("models.card.menu.aria")}
|
||||
data-testid="library-card-menu"
|
||||
onClick={(e) => {
|
||||
// 阻止冒泡到外層 <Link>。
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<MoreVertical aria-hidden className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setVisibilityDialogOpen(true);
|
||||
}}
|
||||
data-testid="library-card-visibility"
|
||||
>
|
||||
<Settings2 aria-hidden className="size-4" />
|
||||
{t("models.visibility.title")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link href={`/models/${model.id}`} data-testid="library-model-card">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2 pr-8">
|
||||
<CardTitle className="text-base leading-tight">{model.name}</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{model.targetChip.toUpperCase()}
|
||||
</Badge>
|
||||
<ModelVisibilityBadge
|
||||
visibility={model.visibility}
|
||||
sharedWithMe={model.sharedWithMe}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("models.size")}</p>
|
||||
<p className="font-medium">{formatFileSize(model.fileSize)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("models.createdAt")}</p>
|
||||
<p className="font-medium">
|
||||
{model.createdAt
|
||||
? new Date(model.createdAt).toLocaleDateString()
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* receiver 視角:顯示分享者(契約不給 email,用 owner.name)。 */}
|
||||
{!isOwner && (
|
||||
<p
|
||||
className="text-muted-foreground mt-3 text-xs"
|
||||
data-testid="library-card-owner-info"
|
||||
>
|
||||
{t("models.sharedByName").replace("{name}", model.owner.name)}
|
||||
{model.updatedAt
|
||||
? ` · ${formatRelativeTime(model.updatedAt, nowMs ?? mountedNow, t)}`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
{isOwner && (
|
||||
<ModelVisibilityDialog
|
||||
modelId={model.id}
|
||||
modelName={model.name}
|
||||
currentVisibility={model.visibility}
|
||||
open={visibilityDialogOpen}
|
||||
onOpenChange={setVisibilityDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
163
visionA-frontend/src/components/models/library-toolbar.tsx
Normal file
163
visionA-frontend/src/components/models/library-toolbar.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* LibraryToolbar — 共享模型庫工具列(搜尋 + filter + 排序)
|
||||
*
|
||||
* 對齊設計規格 §4.3–§4.5。控制 model-sharing-store 的 filters。
|
||||
*
|
||||
* 元素(Mobile First,窄螢幕堆疊 wrap):
|
||||
* - 搜尋框(Input + Search icon + 清除鈕),role="searchbox",debounce 由 parent 處理
|
||||
* - 擁有關係 filter(全部 / 我的 / 共享給我)
|
||||
* - 可見性 filter(全部 / 公開 / 同租戶)
|
||||
* - 晶片 filter(沿用既有 targetChip 選項)
|
||||
* - 排序(最新建立 / 名稱 / 檔案大小)
|
||||
*
|
||||
* 搜尋 debounce:本元件維持 local input state,透過 onSearchChange 通知 parent(parent 做 debounce)。
|
||||
*/
|
||||
|
||||
import { Search, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import type { LibraryFilters } from "@/stores/model-sharing-store";
|
||||
|
||||
interface LibraryToolbarProps {
|
||||
filters: LibraryFilters;
|
||||
/** 搜尋框當前輸入(受控;由 parent 管理以便 debounce)。 */
|
||||
searchInput: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onFilterChange: (patch: Partial<LibraryFilters>) => void;
|
||||
}
|
||||
|
||||
export function LibraryToolbar({
|
||||
filters,
|
||||
searchInput,
|
||||
onSearchChange,
|
||||
onFilterChange,
|
||||
}: LibraryToolbarProps) {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"
|
||||
data-testid="library-toolbar"
|
||||
role="group"
|
||||
aria-label={t("models.filters.label")}
|
||||
>
|
||||
{/* 搜尋框 */}
|
||||
<div className="relative w-full sm:max-w-xs">
|
||||
<Search
|
||||
aria-hidden
|
||||
className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
role="searchbox"
|
||||
value={searchInput}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t("models.search.placeholder")}
|
||||
aria-label={t("models.search.aria")}
|
||||
className="pl-9 pr-9"
|
||||
data-testid="library-search"
|
||||
/>
|
||||
{searchInput && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 size-7 -translate-y-1/2"
|
||||
onClick={() => onSearchChange("")}
|
||||
aria-label={t("models.search.clear")}
|
||||
data-testid="library-search-clear"
|
||||
>
|
||||
<X aria-hidden className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 擁有關係 filter */}
|
||||
<Select
|
||||
value={filters.owned}
|
||||
onValueChange={(v) =>
|
||||
onFilterChange({ owned: v as LibraryFilters["owned"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full sm:w-40" aria-label={t("models.filters.owned")}>
|
||||
<SelectValue placeholder={t("models.filters.owned")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("models.filters.owned.all")}</SelectItem>
|
||||
<SelectItem value="mine">{t("models.filters.owned.mine")}</SelectItem>
|
||||
<SelectItem value="shared">{t("models.filters.owned.shared")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 可見性 filter */}
|
||||
<Select
|
||||
value={filters.visibility}
|
||||
onValueChange={(v) =>
|
||||
onFilterChange({ visibility: v as LibraryFilters["visibility"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-9 w-full sm:w-40"
|
||||
aria-label={t("models.filters.visibility")}
|
||||
>
|
||||
<SelectValue placeholder={t("models.filters.visibility")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("models.filters.all")}</SelectItem>
|
||||
<SelectItem value="public">{t("models.visibility.public")}</SelectItem>
|
||||
<SelectItem value="tenant">{t("models.visibility.tenant")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 晶片 filter */}
|
||||
<Select
|
||||
value={filters.targetChip}
|
||||
onValueChange={(v) =>
|
||||
onFilterChange({ targetChip: v as LibraryFilters["targetChip"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-9 w-full sm:w-36"
|
||||
aria-label={t("models.filters.hardware")}
|
||||
>
|
||||
<SelectValue placeholder={t("models.filters.hardware")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("models.filters.all")}</SelectItem>
|
||||
<SelectItem value="kl520">KL520</SelectItem>
|
||||
<SelectItem value="kl720">KL720</SelectItem>
|
||||
<SelectItem value="kl630">KL630</SelectItem>
|
||||
<SelectItem value="kl730">KL730</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 排序 */}
|
||||
<Select
|
||||
value={filters.sort}
|
||||
onValueChange={(v) =>
|
||||
onFilterChange({ sort: v as LibraryFilters["sort"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full sm:w-40" aria-label={t("models.sort.label")}>
|
||||
<SelectValue placeholder={t("models.sort.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="created_at">{t("models.sort.createdAt")}</SelectItem>
|
||||
<SelectItem value="name">{t("models.sort.name")}</SelectItem>
|
||||
<SelectItem value="file_size">{t("models.sort.fileSize")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
visionA-frontend/src/components/models/model-owner-bar.tsx
Normal file
49
visionA-frontend/src/components/models/model-owner-bar.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ModelOwnerBar — 模型擁有者資訊列(模型共享功能)
|
||||
*
|
||||
* 對齊設計規格 §6.4。僅在非 owner 檢視 profile 時渲染,顯示「由 {ownerName} 共享 · {time}」。
|
||||
*
|
||||
* ⚠️ 契約 §4:response 不揭露 owner email,故用 owner.name(非 email)。
|
||||
* 頭像用 owner.name 首字母(沿用 UserMenu avatar 樣式)。
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { formatRelativeTime } from "@/lib/format/relative-time";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
|
||||
interface ModelOwnerBarProps {
|
||||
ownerName: string;
|
||||
/** 共享時間(ISO);用 updatedAt 近似(契約無獨立 sharedAt 欄)。 */
|
||||
sharedAt?: string;
|
||||
nowMs?: number;
|
||||
}
|
||||
|
||||
export function ModelOwnerBar({ ownerName, sharedAt, nowMs }: ModelOwnerBarProps) {
|
||||
const t = useT();
|
||||
const initial = ownerName.trim().charAt(0).toUpperCase() || "?";
|
||||
// 掛載時固定一次「現在」,避免 render 期呼叫 impure Date.now()(相對時間顯示不需即時更新)。
|
||||
const [mountedNow] = useState(() => Date.now());
|
||||
const relative = sharedAt
|
||||
? formatRelativeTime(sharedAt, nowMs ?? mountedNow, t)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-muted/50 flex items-center gap-2 rounded-md px-3 py-2 text-sm"
|
||||
aria-label={t("models.ownerBar.aria")}
|
||||
data-testid="model-owner-bar"
|
||||
>
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback>{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">
|
||||
{t("models.sharedByName").replace("{name}", ownerName)}
|
||||
{relative ? ` · ${relative}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* ModelVisibilityBadge 測試
|
||||
*
|
||||
* 覆蓋三態雙編碼(圖示 + 文字,不僅靠顏色)+ shared_with_me 優先顯示。
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import type { ModelVisibility } from "@/lib/api/model-sharing";
|
||||
|
||||
import { ModelVisibilityBadge } from "./model-visibility-badge";
|
||||
|
||||
function renderBadge(props: {
|
||||
visibility: ModelVisibility;
|
||||
sharedWithMe?: boolean;
|
||||
sharedCount?: number;
|
||||
}) {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<ModelVisibilityBadge {...props} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ModelVisibilityBadge 三態", () => {
|
||||
it("private → 顯示「私有」文字 + data-visibility=private", () => {
|
||||
renderBadge({ visibility: "private" });
|
||||
const badge = screen.getByTestId("model-visibility-badge");
|
||||
expect(badge).toHaveAttribute("data-visibility", "private");
|
||||
expect(badge).toHaveTextContent("私有");
|
||||
});
|
||||
|
||||
it("public → 顯示「公開」文字 + data-visibility=public", () => {
|
||||
renderBadge({ visibility: "public" });
|
||||
const badge = screen.getByTestId("model-visibility-badge");
|
||||
expect(badge).toHaveAttribute("data-visibility", "public");
|
||||
expect(badge).toHaveTextContent("公開");
|
||||
});
|
||||
|
||||
it("tenant → 顯示「同租戶」文字 + data-visibility=tenant", () => {
|
||||
renderBadge({ visibility: "tenant" });
|
||||
const badge = screen.getByTestId("model-visibility-badge");
|
||||
expect(badge).toHaveAttribute("data-visibility", "tenant");
|
||||
expect(badge).toHaveTextContent("同租戶");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelVisibilityBadge sharedWithMe 優先", () => {
|
||||
it("sharedWithMe=true → 顯示「共享給我」+ data-visibility=shared(覆蓋 visibility)", () => {
|
||||
renderBadge({ visibility: "public", sharedWithMe: true });
|
||||
const badge = screen.getByTestId("model-visibility-badge");
|
||||
expect(badge).toHaveAttribute("data-visibility", "shared");
|
||||
expect(badge).toHaveTextContent("共享給我");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelVisibilityBadge sharedCount 尾綴", () => {
|
||||
it("public + sharedCount=3 → 顯示「· 3 人」", () => {
|
||||
renderBadge({ visibility: "public", sharedCount: 3 });
|
||||
const badge = screen.getByTestId("model-visibility-badge");
|
||||
expect(badge).toHaveTextContent("3 人");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ModelVisibilityBadge — 模型可見性徽章(模型共享功能)
|
||||
*
|
||||
* 三態雙編碼(圖示 + 文字,不僅靠顏色,滿足無障礙 De1):
|
||||
* - private(Lock,中性):owner 檢視自己的私有模型
|
||||
* - public(Globe,chart-2 tint):公開給所有 visionA 使用者
|
||||
* - tenant(Building2,chart-1 tint):同租戶可見
|
||||
*
|
||||
* 另有「共享給我」的獨立標示(sharedWithMe=true → Users,chart-3 tint):
|
||||
* 此態源自 model_shares 維度(與 visibility 正交,見 api-model-sharing.md §0),
|
||||
* 優先於 visibility 顯示——receiver 最關心的是「這是別人分享給我的」。
|
||||
*
|
||||
* 配色沿用既有 chart-* token tint 風格(bg/10 + 純色文字 + /30 邊框),
|
||||
* 與 model-card 的 source badge 同做法,Dark Mode 由 token 自動處理,零新 token。
|
||||
*/
|
||||
|
||||
import { Building2, Globe, Lock, Users } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import type { ModelVisibility } from "@/lib/api/model-sharing";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModelVisibilityBadgeProps {
|
||||
visibility: ModelVisibility;
|
||||
/** model_shares 命中(別人分享給我)→ 優先顯示「共享給我」。 */
|
||||
sharedWithMe?: boolean;
|
||||
/** owner 視角:已分享給幾人(顯示在 badge 尾綴,如「公開 · 3 人」)。可選。 */
|
||||
sharedCount?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VISIBILITY_META: Record<
|
||||
ModelVisibility,
|
||||
{ icon: typeof Lock; labelKey: string; className: string }
|
||||
> = {
|
||||
private: {
|
||||
icon: Lock,
|
||||
labelKey: "models.visibility.badge.private",
|
||||
className: "text-muted-foreground border-border",
|
||||
},
|
||||
public: {
|
||||
icon: Globe,
|
||||
labelKey: "models.visibility.badge.public",
|
||||
className: "border-chart-2/30 bg-chart-2/10 text-chart-2",
|
||||
},
|
||||
tenant: {
|
||||
icon: Building2,
|
||||
labelKey: "models.visibility.badge.tenant",
|
||||
className: "border-chart-1/30 bg-chart-1/10 text-chart-1",
|
||||
},
|
||||
};
|
||||
|
||||
export function ModelVisibilityBadge({
|
||||
visibility,
|
||||
sharedWithMe = false,
|
||||
sharedCount,
|
||||
className,
|
||||
}: ModelVisibilityBadgeProps) {
|
||||
const t = useT();
|
||||
|
||||
// 「共享給我」優先於 visibility 顯示(receiver 視角)。
|
||||
if (sharedWithMe) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("border-chart-3/30 bg-chart-3/10 text-chart-3 gap-1 text-xs", className)}
|
||||
data-testid="model-visibility-badge"
|
||||
data-visibility="shared"
|
||||
>
|
||||
<Users aria-hidden className="size-3" />
|
||||
{t("models.visibility.badge.sharedWithMe")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = VISIBILITY_META[visibility];
|
||||
const Icon = meta.icon;
|
||||
const label = t(meta.labelKey);
|
||||
// owner 視角:public/tenant 且有分享人數時,尾綴「· N 人」。
|
||||
const suffix =
|
||||
sharedCount && sharedCount > 0
|
||||
? ` · ${t("models.visibility.badge.sharedCount").replace("{n}", String(sharedCount))}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("gap-1 text-xs", meta.className, className)}
|
||||
data-testid="model-visibility-badge"
|
||||
data-visibility={visibility}
|
||||
>
|
||||
<Icon aria-hidden className="size-3" />
|
||||
{label}
|
||||
{suffix}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,211 @@
|
||||
/**
|
||||
* ModelVisibilityDialog 測試
|
||||
*
|
||||
* 覆蓋:
|
||||
* - 三態選項渲染(私有 / 公開 / 同租戶)
|
||||
* - public 選中 → amber 警告條顯示
|
||||
* - email 加入:格式錯 → inline 錯誤;重複 → 提示;合法 → 呼叫 store.addShare
|
||||
* - 授權清單渲染 + 移除
|
||||
* - 儲存 → 呼叫 store.updateVisibility + toast.success + 關閉
|
||||
*
|
||||
* 走 store mock 模式(_setMockMode true)+ 直接注入 shares,避免打真實 API。
|
||||
* Radix Dialog / RadioGroup 在 jsdom:dialog 受控 open=true,內容 render 到 portal 可查。
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { useModelSharingStore } from "@/stores/model-sharing-store";
|
||||
|
||||
vi.mock("sonner", () => {
|
||||
const success = vi.fn();
|
||||
const error = vi.fn();
|
||||
return { toast: Object.assign(vi.fn(), { success, error }) };
|
||||
});
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ModelVisibilityDialog } from "./model-visibility-dialog";
|
||||
|
||||
function resetStore() {
|
||||
useModelSharingStore.setState({
|
||||
shares: [],
|
||||
isSharesLoading: false,
|
||||
_mockMode: true,
|
||||
// stub loadShares 為 noop:測試自行以 setState 注入 shares,
|
||||
// 避免 body 掛載時 mock loadShares 覆蓋注入的清單。
|
||||
loadShares: async () => {},
|
||||
});
|
||||
}
|
||||
|
||||
function renderDialog(currentVisibility: "private" | "public" | "tenant" = "private") {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<ModelVisibilityDialog
|
||||
modelId="mock-model-01"
|
||||
modelName="測試模型"
|
||||
currentVisibility={currentVisibility}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
(toast.success as Mock).mockReset();
|
||||
(toast.error as Mock).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("三態選項渲染", () => {
|
||||
it("顯示私有 / 公開 / 同租戶三選項", async () => {
|
||||
renderDialog();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("model-visibility-dialog")).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByRole("radio", { name: "私有" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "公開" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "同租戶" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("public 警告條", () => {
|
||||
it("初始 currentVisibility=public → 顯示 amber 警告", async () => {
|
||||
renderDialog("public");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("visibility-public-warning")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("初始 private → 不顯示警告", async () => {
|
||||
renderDialog("private");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("model-visibility-dialog")).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByTestId("visibility-public-warning")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("email 加入驗證", () => {
|
||||
it("格式錯 → inline 錯誤,不呼叫 addShare", async () => {
|
||||
const spy = vi.spyOn(useModelSharingStore.getState(), "addShare");
|
||||
renderDialog();
|
||||
await waitFor(() => screen.getByTestId("share-email-input"));
|
||||
|
||||
fireEvent.change(screen.getByTestId("share-email-input"), {
|
||||
target: { value: "not-an-email" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("share-email-add"));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Email 格式不正確")).toBeInTheDocument());
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("重複 email → 提示已在清單中", async () => {
|
||||
useModelSharingStore.setState({
|
||||
shares: [
|
||||
{ userId: "u1", email: "dup@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
|
||||
],
|
||||
});
|
||||
renderDialog();
|
||||
await waitFor(() => screen.getByTestId("share-email-input"));
|
||||
|
||||
fireEvent.change(screen.getByTestId("share-email-input"), {
|
||||
target: { value: "dup@corp.com" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("share-email-add"));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("已在清單中")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("合法 email → 呼叫 addShare", async () => {
|
||||
const spy = vi
|
||||
.spyOn(useModelSharingStore.getState(), "addShare")
|
||||
.mockResolvedValue({ ok: true });
|
||||
renderDialog();
|
||||
await waitFor(() => screen.getByTestId("share-email-input"));
|
||||
|
||||
fireEvent.change(screen.getByTestId("share-email-input"), {
|
||||
target: { value: "new@corp.com" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("share-email-add"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith("mock-model-01", "new@corp.com"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("授權清單", () => {
|
||||
it("渲染既有 shares + 移除鈕", async () => {
|
||||
useModelSharingStore.setState({
|
||||
shares: [
|
||||
{ userId: "u1", email: "alice@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
|
||||
],
|
||||
});
|
||||
renderDialog();
|
||||
await waitFor(() => expect(screen.getByText("alice@corp.com")).toBeInTheDocument());
|
||||
expect(screen.getByTestId("share-remove")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("點移除 → 呼叫 removeShare", async () => {
|
||||
useModelSharingStore.setState({
|
||||
shares: [
|
||||
{ userId: "u1", email: "alice@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
|
||||
],
|
||||
});
|
||||
const spy = vi
|
||||
.spyOn(useModelSharingStore.getState(), "removeShare")
|
||||
.mockResolvedValue({ ok: true });
|
||||
renderDialog();
|
||||
await waitFor(() => screen.getByTestId("share-remove"));
|
||||
|
||||
fireEvent.click(screen.getByTestId("share-remove"));
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith("mock-model-01", "u1"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("儲存", () => {
|
||||
it("點儲存(private,無變更)→ updateVisibility + toast.success", async () => {
|
||||
const spy = vi
|
||||
.spyOn(useModelSharingStore.getState(), "updateVisibility")
|
||||
.mockResolvedValue({ ok: true });
|
||||
renderDialog("private");
|
||||
await waitFor(() => screen.getByTestId("visibility-save"));
|
||||
|
||||
fireEvent.click(screen.getByTestId("visibility-save"));
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith("mock-model-01", "private"),
|
||||
);
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("由 public 收回成 private 且有授權對象 → 先跳二次確認(不直接 save)", async () => {
|
||||
useModelSharingStore.setState({
|
||||
shares: [
|
||||
{ userId: "u1", email: "alice@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
|
||||
],
|
||||
});
|
||||
const spy = vi
|
||||
.spyOn(useModelSharingStore.getState(), "updateVisibility")
|
||||
.mockResolvedValue({ ok: true });
|
||||
renderDialog("public");
|
||||
await waitFor(() => screen.getByRole("radio", { name: "私有" }));
|
||||
|
||||
// 選「私有」
|
||||
fireEvent.click(screen.getByRole("radio", { name: "私有" }));
|
||||
fireEvent.click(screen.getByTestId("visibility-save"));
|
||||
|
||||
// 應出現二次確認,updateVisibility 尚未被呼叫
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/改為私有後/)).toBeInTheDocument(),
|
||||
);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,385 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ModelVisibilityDialog — 模型公開設定(owner-only)
|
||||
*
|
||||
* 對齊設計規格 §5 + API 契約 §3(PATCH visibility)+ shares API(§4)。
|
||||
*
|
||||
* 內容:
|
||||
* - RadioGroup 三態:private / public / tenant(同租戶)
|
||||
* (API 契約 visibility 三態;PRD 的 restricted「指定對象」對應正交的 model_shares 維度,
|
||||
* 下方獨立區塊管理,不佔 visibility 選項)
|
||||
* - 指定對象(model_shares):email 加入 + 授權清單管理(永遠可用,與 visibility 正交)
|
||||
* - public 選中 → amber 警告條
|
||||
* - 由 public/tenant 改回 private 且曾分享給人 → AlertDialog 二次確認
|
||||
*
|
||||
* 受控元件:由 parent(卡片 ⋮ 選單 / profile 按鈕)以 open / onOpenChange 控制。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Globe, Lock, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { isValidEmail, type ModelVisibility } from "@/lib/api/model-sharing";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useModelSharingStore } from "@/stores/model-sharing-store";
|
||||
|
||||
interface ModelVisibilityDialogProps {
|
||||
modelId: string;
|
||||
modelName: string;
|
||||
/** 目前的可見性(開啟時的初始值)。 */
|
||||
currentVisibility: ModelVisibility;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/** 三態選項(對齊 i18n key)。 */
|
||||
const VISIBILITY_OPTIONS: ReadonlyArray<{
|
||||
value: ModelVisibility;
|
||||
labelKey: string;
|
||||
descKey: string;
|
||||
}> = [
|
||||
{ value: "private", labelKey: "models.visibility.private", descKey: "models.visibility.private.desc" },
|
||||
{ value: "public", labelKey: "models.visibility.public", descKey: "models.visibility.public.desc" },
|
||||
{ value: "tenant", labelKey: "models.visibility.tenant", descKey: "models.visibility.tenant.desc" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 外層殼:受控 Dialog。內容以 key remount,讓每次開啟時 body 用最新 props 初始化 state
|
||||
* (避免 setState-in-effect 的 cascading render)。
|
||||
*/
|
||||
export function ModelVisibilityDialog(props: ModelVisibilityDialogProps) {
|
||||
const { open, onOpenChange } = props;
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{/* key 綁 modelId:開啟不同模型時 body remount,state 重新以 props 初始化。 */}
|
||||
{open && <VisibilityDialogBody key={props.modelId} {...props} />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog body(僅在 open 時掛載)。state 直接以 props 初始化(無 setState-in-effect);
|
||||
* 唯一副作用是掛載時載入 shares(external sync,合法 effect)。
|
||||
*/
|
||||
function VisibilityDialogBody({
|
||||
modelId,
|
||||
modelName,
|
||||
currentVisibility,
|
||||
onOpenChange,
|
||||
}: ModelVisibilityDialogProps) {
|
||||
const t = useT();
|
||||
|
||||
const shares = useModelSharingStore((s) => s.shares);
|
||||
const isSharesLoading = useModelSharingStore((s) => s.isSharesLoading);
|
||||
const loadShares = useModelSharingStore((s) => s.loadShares);
|
||||
const updateVisibility = useModelSharingStore((s) => s.updateVisibility);
|
||||
const addShareAction = useModelSharingStore((s) => s.addShare);
|
||||
const removeShareAction = useModelSharingStore((s) => s.removeShare);
|
||||
|
||||
const [visibility, setVisibility] = useState<ModelVisibility>(currentVisibility);
|
||||
const [emailInput, setEmailInput] = useState("");
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [addingEmail, setAddingEmail] = useState(false);
|
||||
const [confirmRevokeOpen, setConfirmRevokeOpen] = useState(false);
|
||||
|
||||
// 掛載時載入授權清單(external sync);modelId 於本 body 生命週期固定(key remount)。
|
||||
// loadShares 是 zustand action,身分穩定,可安全放入 deps。
|
||||
useEffect(() => {
|
||||
void loadShares(modelId);
|
||||
}, [modelId, loadShares]);
|
||||
|
||||
async function handleAddEmail() {
|
||||
const email = emailInput.trim();
|
||||
if (!email) return;
|
||||
if (!isValidEmail(email)) {
|
||||
setEmailError(t("models.visibility.emailInvalid"));
|
||||
return;
|
||||
}
|
||||
if (shares.some((s) => s.email.toLowerCase() === email.toLowerCase())) {
|
||||
setEmailError(t("models.visibility.emailDuplicate"));
|
||||
return;
|
||||
}
|
||||
setEmailError(null);
|
||||
setAddingEmail(true);
|
||||
const result = await addShareAction(modelId, email);
|
||||
setAddingEmail(false);
|
||||
if (result.ok) {
|
||||
setEmailInput("");
|
||||
} else if (result.code === "not_found") {
|
||||
setEmailError(t("models.visibility.userNotFound").replace("{email}", email));
|
||||
} else {
|
||||
setEmailError(t("models.sharing.error.generic"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveShare(userId: string) {
|
||||
const result = await removeShareAction(modelId, userId);
|
||||
if (!result.ok) {
|
||||
toast.error(t("models.sharing.error.generic"));
|
||||
}
|
||||
}
|
||||
|
||||
/** 執行儲存(可能先過二次確認)。 */
|
||||
async function doSave() {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const result = await updateVisibility(modelId, visibility);
|
||||
setSaving(false);
|
||||
if (result.ok) {
|
||||
toast.success(t("models.visibility.saved"));
|
||||
onOpenChange(false);
|
||||
} else if (result.code === "conflict") {
|
||||
setSaveError(t("models.visibility.notReady"));
|
||||
} else {
|
||||
setSaveError(t("models.visibility.saveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveClick() {
|
||||
// 由 public/tenant 收回成 private 且曾有授權對象 → 二次確認。
|
||||
const wasBroadcast = currentVisibility === "public" || currentVisibility === "tenant";
|
||||
if (visibility === "private" && wasBroadcast && shares.length > 0) {
|
||||
setConfirmRevokeOpen(true);
|
||||
return;
|
||||
}
|
||||
void doSave();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogContent className="max-w-lg" data-testid="model-visibility-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("models.visibility.title")} — {modelName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium">{t("models.visibility.question")}</p>
|
||||
|
||||
<RadioGroup
|
||||
value={visibility}
|
||||
onValueChange={(v) => setVisibility(v as ModelVisibility)}
|
||||
aria-label={t("models.visibility.question")}
|
||||
>
|
||||
{VISIBILITY_OPTIONS.map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
htmlFor={`visibility-${opt.value}`}
|
||||
className="hover:bg-accent/40 flex cursor-pointer items-start gap-3 rounded-md border p-3"
|
||||
data-testid={`visibility-option-${opt.value}`}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={opt.value}
|
||||
id={`visibility-${opt.value}`}
|
||||
aria-label={t(opt.labelKey)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block text-sm font-medium">{t(opt.labelKey)}</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t(opt.descKey)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
|
||||
{/* public 警告條(沿用 §2.1 amber 半語義約定)。 */}
|
||||
{visibility === "public" && (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-950/30 dark:text-amber-200"
|
||||
role="alert"
|
||||
data-testid="visibility-public-warning"
|
||||
>
|
||||
<Globe aria-hidden className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{t("models.visibility.publicWarning")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 指定對象(model_shares)管理,與 visibility 正交,永遠可用。 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Label htmlFor="share-email-input" className="text-sm font-medium">
|
||||
{t("models.visibility.sharedPeopleTitle")}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
id="share-email-input"
|
||||
type="email"
|
||||
value={emailInput}
|
||||
onChange={(e) => {
|
||||
setEmailInput(e.target.value);
|
||||
if (emailError) setEmailError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleAddEmail();
|
||||
}
|
||||
}}
|
||||
placeholder={t("models.visibility.addEmail")}
|
||||
aria-label={t("models.visibility.addEmail")}
|
||||
aria-invalid={emailError ? true : undefined}
|
||||
data-testid="share-email-input"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void handleAddEmail()}
|
||||
disabled={addingEmail || !emailInput.trim()}
|
||||
data-testid="share-email-add"
|
||||
>
|
||||
{addingEmail ? (
|
||||
<Spinner size="sm" label={t("common.loading")} />
|
||||
) : (
|
||||
t("models.visibility.addButton")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{emailError && (
|
||||
<p className="text-destructive text-xs" role="alert">
|
||||
{emailError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 授權清單 */}
|
||||
<div
|
||||
className="max-h-40 space-y-1 overflow-y-auto"
|
||||
data-testid="share-list"
|
||||
>
|
||||
{isSharesLoading ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : shares.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">
|
||||
{t("models.visibility.noShares")}
|
||||
</p>
|
||||
) : (
|
||||
shares.map((share) => (
|
||||
<div
|
||||
key={share.userId}
|
||||
className="bg-muted/50 flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="truncate">{share.email}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("models.visibility.permissionViewDownload")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => void handleRemoveShare(share.userId)}
|
||||
aria-label={t("models.visibility.removeShare").replace(
|
||||
"{email}",
|
||||
share.email,
|
||||
)}
|
||||
data-testid="share-remove"
|
||||
>
|
||||
<X aria-hidden className="size-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div
|
||||
className={cn(
|
||||
"border-destructive/30 bg-destructive/10 text-destructive rounded-md border p-3 text-xs",
|
||||
)}
|
||||
role="alert"
|
||||
data-testid="visibility-save-error"
|
||||
>
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSaveClick}
|
||||
disabled={saving}
|
||||
data-testid="visibility-save"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Spinner size="sm" label={t("common.loading")} />
|
||||
{t("common.loading")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock aria-hidden className="size-4" />
|
||||
{t("models.visibility.saveButton")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
{/* 收回權限二次確認 */}
|
||||
<AlertDialog open={confirmRevokeOpen} onOpenChange={setConfirmRevokeOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("common.confirm")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("models.visibility.revokeConfirm")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setConfirmRevokeOpen(false);
|
||||
void doSave();
|
||||
}}
|
||||
>
|
||||
{t("models.visibility.saveButton")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
55
visionA-frontend/src/components/ui/radio-group.tsx
Normal file
55
visionA-frontend/src/components/ui/radio-group.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CircleIcon } from "lucide-react";
|
||||
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* RadioGroup — Shadcn 風單選群組(Radix RadioGroup 封裝)
|
||||
*
|
||||
* 新增於「模型共享」功能:公開設定 Dialog 的三態選擇(私有 / 公開 / 指定對象)。
|
||||
* shadcn 標準元件,radix-ui 已含 RadioGroup primitive(見 package.json radix-ui ^1.4.3)。
|
||||
*
|
||||
* 無障礙(Radix 內建):
|
||||
* - role="radiogroup" / role="radio"、Arrow 鍵切換、aria-checked
|
||||
* - 每個 Item 需搭配可見 <Label htmlFor> 或包在 label 內
|
||||
*/
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
62
visionA-frontend/src/hooks/use-infinite-scroll.ts
Normal file
62
visionA-frontend/src/hooks/use-infinite-scroll.ts
Normal file
@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* useInfiniteScroll — 無限捲動偵測 hook
|
||||
*
|
||||
* 用 IntersectionObserver 觀察一個「哨兵」元素,當它進入視窗(+ rootMargin 提前量)
|
||||
* 時觸發 `onLoadMore`。用於共享模型庫的 cursor 無限捲動(設計規格 §4.6,使用者拍板無限捲動)。
|
||||
*
|
||||
* 設計要點:
|
||||
* - `enabled=false`(如 hasMore=false / loading 中)時不觀察,避免無謂觸發。
|
||||
* - `onLoadMore` 以 ref 保存最新值,避免因回呼身分變動而反覆重建 observer。
|
||||
* - rootMargin 預設 `200px`:在哨兵距離視窗底部 200px 時就預載,捲動更順。
|
||||
* - store 的 loadMore 已自帶「重入防護」(isLoadingMore / hasMore 檢查),
|
||||
* 故即使 observer 連續觸發也安全。
|
||||
*
|
||||
* 回傳 `sentinelRef`,掛到列表末端的哨兵 div 上。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
interface UseInfiniteScrollOptions {
|
||||
/** 是否啟用觀察(通常 = hasMore && !isLoading)。 */
|
||||
enabled: boolean;
|
||||
/** 觸達哨兵時呼叫。 */
|
||||
onLoadMore: () => void;
|
||||
/** 提前量(哨兵距視窗多遠就觸發)。預設 "200px"。 */
|
||||
rootMargin?: string;
|
||||
}
|
||||
|
||||
export function useInfiniteScroll({
|
||||
enabled,
|
||||
onLoadMore,
|
||||
rootMargin = "200px",
|
||||
}: UseInfiniteScrollOptions) {
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const onLoadMoreRef = useRef(onLoadMore);
|
||||
|
||||
// 保持最新 callback,避免 observer 因 callback 身分變動而重建。
|
||||
useEffect(() => {
|
||||
onLoadMoreRef.current = onLoadMore;
|
||||
}, [onLoadMore]);
|
||||
|
||||
const handleIntersect = useCallback<IntersectionObserverCallback>((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry?.isIntersecting) {
|
||||
onLoadMoreRef.current();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
// 環境不支援 IntersectionObserver(如部分測試環境)時安全退出。
|
||||
if (!enabled || !sentinel || typeof IntersectionObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(handleIntersect, { rootMargin });
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [enabled, handleIntersect, rootMargin]);
|
||||
|
||||
return { sentinelRef };
|
||||
}
|
||||
155
visionA-frontend/src/lib/api/model-sharing.mock.ts
Normal file
155
visionA-frontend/src/lib/api/model-sharing.mock.ts
Normal file
@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Model Sharing — Mock Fixtures(平行開發用)
|
||||
*
|
||||
* 對 API 契約 mock(api-model-sharing.md 的 response 形狀),不依賴後端跑起來。
|
||||
* 形狀為契約定義的 snake_case,經 model-sharing.ts 的 normalize 後才成前端型別。
|
||||
*
|
||||
* 用途:
|
||||
* 1. store 在 `NEXT_PUBLIC_USE_MODEL_SHARING_MOCK=1` 時走 mock(見 model-sharing-store)
|
||||
* 2. 元件測試 fixture 來源
|
||||
*
|
||||
* ⚠️ mock owner 資料刻意不含 email(對齊契約 §4:非擁有者不該看到他人 email)。
|
||||
* shares 的 email 是 owner 授權目標,才有 email。
|
||||
*/
|
||||
|
||||
/** 契約 §1 library item 的原始(snake_case)形狀。 */
|
||||
export interface RawLibraryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
target_chip: string;
|
||||
file_size: number;
|
||||
source: string;
|
||||
status: string;
|
||||
visibility: string;
|
||||
owner: { id: string; name: string; is_me: boolean };
|
||||
shared_with_me: boolean;
|
||||
my_access: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 契約 §1 分頁 response 原始形狀。 */
|
||||
export interface RawLibraryPage {
|
||||
items: RawLibraryItem[];
|
||||
next_cursor: string | null;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
const NOW = "2026-08-01T00:00:00Z";
|
||||
|
||||
/**
|
||||
* 一個較大的 mock 資料集(30 筆),用來驗證 cursor 無限捲動分頁。
|
||||
* 混合三種可見性、owner/非 owner、shared_with_me 各態,覆蓋 UI 分支。
|
||||
*/
|
||||
export const MOCK_LIBRARY_ITEMS: RawLibraryItem[] = Array.from({ length: 30 }).map(
|
||||
(_, i) => {
|
||||
const isMine = i % 3 === 0;
|
||||
const visibility = isMine ? "private" : i % 3 === 1 ? "public" : "tenant";
|
||||
const sharedWithMe = !isMine && i % 4 === 0;
|
||||
const chips = ["kl520", "kl720", "kl630", "kl730"];
|
||||
const sources = ["converted", "uploaded", "preset"];
|
||||
return {
|
||||
id: `mock-model-${String(i + 1).padStart(2, "0")}`,
|
||||
name: `mock-model-${i + 1} ${["yolov5s", "resnet50", "mobilenet", "ssd"][i % 4]}`,
|
||||
description: i % 2 === 0 ? `Mock 模型 ${i + 1} 的描述` : undefined,
|
||||
target_chip: chips[i % chips.length],
|
||||
file_size: (i + 1) * 1024 * 1024,
|
||||
source: isMine ? "converted" : sources[i % sources.length],
|
||||
status: "ready",
|
||||
visibility,
|
||||
owner: isMine
|
||||
? { id: "me", name: "我", is_me: true }
|
||||
: { id: `owner-${i}`, name: `Owner ${(i % 5) + 1}`, is_me: false },
|
||||
shared_with_me: sharedWithMe,
|
||||
my_access: isMine ? "owner" : sharedWithMe ? "viewer" : "viewer",
|
||||
created_at: `2026-07-${String((i % 28) + 1).padStart(2, "0")}T00:00:00Z`,
|
||||
updated_at: NOW,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 對 MOCK_LIBRARY_ITEMS 套用 query(搜尋 / filter / 排序 / cursor 分頁)產生一頁。
|
||||
* cursor = 已消費筆數的 base64(不透明;mock 只需能往下切)。
|
||||
*/
|
||||
export function mockLibraryPage(query: {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
q?: string;
|
||||
targetChip?: string;
|
||||
source?: string;
|
||||
visibility?: string;
|
||||
owned?: boolean;
|
||||
sort?: string;
|
||||
order?: string;
|
||||
}): RawLibraryPage {
|
||||
let items = [...MOCK_LIBRARY_ITEMS];
|
||||
|
||||
// filter
|
||||
if (query.q) {
|
||||
const q = query.q.toLowerCase();
|
||||
items = items.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
(m.description ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
if (query.targetChip) {
|
||||
items = items.filter((m) => m.target_chip === query.targetChip);
|
||||
}
|
||||
if (query.source) {
|
||||
items = items.filter((m) => m.source === query.source);
|
||||
}
|
||||
if (query.visibility) {
|
||||
items = items.filter((m) => m.visibility === query.visibility);
|
||||
}
|
||||
if (query.owned === true) {
|
||||
items = items.filter((m) => m.owner.is_me);
|
||||
} else if (query.owned === false) {
|
||||
items = items.filter((m) => !m.owner.is_me);
|
||||
}
|
||||
|
||||
// sort
|
||||
const order = query.order === "asc" ? 1 : -1;
|
||||
const sortKey = query.sort ?? "created_at";
|
||||
items.sort((a, b) => {
|
||||
if (sortKey === "name") return a.name.localeCompare(b.name) * order;
|
||||
if (sortKey === "file_size") return (a.file_size - b.file_size) * order;
|
||||
return a.created_at.localeCompare(b.created_at) * order;
|
||||
});
|
||||
|
||||
// cursor 分頁
|
||||
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
|
||||
const start = query.cursor ? Number(atob(query.cursor)) : 0;
|
||||
const slice = items.slice(start, start + limit);
|
||||
const nextStart = start + slice.length;
|
||||
const hasMore = nextStart < items.length;
|
||||
return {
|
||||
items: slice,
|
||||
next_cursor: hasMore ? btoa(String(nextStart)) : null,
|
||||
has_more: hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
/** 依 id 產生一筆 profile 原始形狀(找不到回 null,模擬 404)。 */
|
||||
export function mockProfile(id: string): Record<string, unknown> | null {
|
||||
const item = MOCK_LIBRARY_ITEMS.find((m) => m.id === id);
|
||||
if (!item) return null;
|
||||
return {
|
||||
...item,
|
||||
input_shape: [1, 3, 224, 224],
|
||||
classes: ["person", "car", "dog", "cat"],
|
||||
framework: "onnx",
|
||||
can_download: item.my_access !== "none",
|
||||
uploaded_at: item.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** mock shares 清單(owner 檢視自己模型的授權對象)。 */
|
||||
export const MOCK_SHARES: Record<string, Array<{ user_id: string; email: string; role: string; created_at: string }>> = {
|
||||
"mock-model-01": [
|
||||
{ user_id: "u-alice", email: "alice@corp.com", role: "viewer", created_at: NOW },
|
||||
{ user_id: "u-bob", email: "bob@corp.com", role: "viewer", created_at: NOW },
|
||||
],
|
||||
};
|
||||
180
visionA-frontend/src/lib/api/model-sharing.test.ts
Normal file
180
visionA-frontend/src/lib/api/model-sharing.test.ts
Normal file
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Model Sharing API Client 測試
|
||||
*
|
||||
* 覆蓋:
|
||||
* - normalize:snake_case → camelCase、visibility / access 收斂、防呆預設
|
||||
* - mock 分頁:cursor 切頁、filter、排序(deterministic,不打真實 API)
|
||||
* - isValidEmail
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isValidEmail,
|
||||
normalizeLibraryModel,
|
||||
normalizeLibraryPage,
|
||||
normalizeProfile,
|
||||
} from "./model-sharing";
|
||||
import { mockLibraryPage, mockProfile, MOCK_LIBRARY_ITEMS } from "./model-sharing.mock";
|
||||
|
||||
describe("normalizeLibraryModel", () => {
|
||||
it("snake_case → camelCase + 收斂 target_chip 大小寫", () => {
|
||||
const m = normalizeLibraryModel({
|
||||
id: "m1",
|
||||
name: "YOLO",
|
||||
target_chip: "KL520",
|
||||
file_size: 2048,
|
||||
source: "converted",
|
||||
status: "ready",
|
||||
visibility: "public",
|
||||
owner: { id: "o1", name: "Alice", is_me: false },
|
||||
shared_with_me: true,
|
||||
my_access: "viewer",
|
||||
created_at: "2026-07-01T00:00:00Z",
|
||||
updated_at: "2026-07-02T00:00:00Z",
|
||||
});
|
||||
expect(m.targetChip).toBe("kl520");
|
||||
expect(m.fileSize).toBe(2048);
|
||||
expect(m.visibility).toBe("public");
|
||||
expect(m.owner.isMe).toBe(false);
|
||||
expect(m.sharedWithMe).toBe(true);
|
||||
expect(m.myAccess).toBe("viewer");
|
||||
});
|
||||
|
||||
it("非法 visibility → 收斂為 private;非法 access → none", () => {
|
||||
const m = normalizeLibraryModel({
|
||||
id: "m2",
|
||||
visibility: "weird",
|
||||
my_access: "hacker",
|
||||
owner: {},
|
||||
});
|
||||
expect(m.visibility).toBe("private");
|
||||
expect(m.myAccess).toBe("none");
|
||||
});
|
||||
|
||||
it("缺欄位 → 安全預設(不 throw)", () => {
|
||||
const m = normalizeLibraryModel({});
|
||||
expect(m.id).toBe("");
|
||||
expect(m.fileSize).toBe(0);
|
||||
expect(m.source).toBe("uploaded");
|
||||
expect(m.visibility).toBe("private");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeLibraryPage", () => {
|
||||
it("解析 items + next_cursor + has_more", () => {
|
||||
const page = normalizeLibraryPage({
|
||||
items: [{ id: "a" }, { id: "b" }],
|
||||
next_cursor: "abc",
|
||||
has_more: true,
|
||||
});
|
||||
expect(page.items).toHaveLength(2);
|
||||
expect(page.nextCursor).toBe("abc");
|
||||
expect(page.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("無 next_cursor → null", () => {
|
||||
const page = normalizeLibraryPage({ items: [], next_cursor: null, has_more: false });
|
||||
expect(page.nextCursor).toBeNull();
|
||||
expect(page.hasMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeProfile", () => {
|
||||
it("解析 input_shape / classes / can_download", () => {
|
||||
const p = normalizeProfile({
|
||||
id: "p1",
|
||||
name: "N",
|
||||
target_chip: "kl720",
|
||||
input_shape: [1, 3, 224, 224],
|
||||
classes: ["cat", "dog"],
|
||||
can_download: true,
|
||||
my_access: "owner",
|
||||
owner: { id: "o", name: "Me", is_me: true },
|
||||
});
|
||||
expect(p.inputShape).toEqual([1, 3, 224, 224]);
|
||||
expect(p.classes).toEqual(["cat", "dog"]);
|
||||
expect(p.canDownload).toBe(true);
|
||||
expect(p.myAccess).toBe("owner");
|
||||
});
|
||||
|
||||
it("空 classes 陣列 → undefined(UI 有值才顯示)", () => {
|
||||
const p = normalizeProfile({ id: "p2", classes: [], owner: {} });
|
||||
expect(p.classes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mockLibraryPage — cursor 分頁", () => {
|
||||
it("首頁 limit=10 → 回 10 筆 + has_more + next_cursor", () => {
|
||||
const page = mockLibraryPage({ limit: 10 });
|
||||
expect(page.items).toHaveLength(10);
|
||||
expect(page.has_more).toBe(true);
|
||||
expect(page.next_cursor).not.toBeNull();
|
||||
});
|
||||
|
||||
it("用 next_cursor 續載 → 不重複、能一路切到底", () => {
|
||||
const seen = new Set<string>();
|
||||
let cursor: string | undefined;
|
||||
let guard = 0;
|
||||
for (;;) {
|
||||
const page: ReturnType<typeof mockLibraryPage> = mockLibraryPage({ limit: 7, cursor });
|
||||
for (const item of page.items) {
|
||||
expect(seen.has(item.id)).toBe(false); // 不重複
|
||||
seen.add(item.id);
|
||||
}
|
||||
if (!page.has_more || !page.next_cursor) break;
|
||||
cursor = page.next_cursor;
|
||||
if (++guard > 20) throw new Error("cursor 未收斂");
|
||||
}
|
||||
expect(seen.size).toBe(MOCK_LIBRARY_ITEMS.length);
|
||||
});
|
||||
|
||||
it("filter owned=true → 只回我的(owner.is_me)", () => {
|
||||
const page = mockLibraryPage({ limit: 100, owned: true });
|
||||
expect(page.items.every((m) => m.owner.is_me)).toBe(true);
|
||||
expect(page.items.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("filter visibility=public → 只回 public", () => {
|
||||
const page = mockLibraryPage({ limit: 100, visibility: "public" });
|
||||
expect(page.items.every((m) => m.visibility === "public")).toBe(true);
|
||||
});
|
||||
|
||||
it("搜尋 q 無 match → 空頁 + has_more=false", () => {
|
||||
const page = mockLibraryPage({ limit: 100, q: "zzz-no-such-model" });
|
||||
expect(page.items).toHaveLength(0);
|
||||
expect(page.has_more).toBe(false);
|
||||
});
|
||||
|
||||
it("sort=name asc → 名稱遞增", () => {
|
||||
const page = mockLibraryPage({ limit: 100, sort: "name", order: "asc" });
|
||||
const names = page.items.map((m) => m.name);
|
||||
const sorted = [...names].sort((a, b) => a.localeCompare(b));
|
||||
expect(names).toEqual(sorted);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mockProfile", () => {
|
||||
it("存在 id → 回 profile 原始形狀(含 can_download)", () => {
|
||||
const raw = mockProfile("mock-model-01");
|
||||
expect(raw).not.toBeNull();
|
||||
expect(raw!.can_download).toBeDefined();
|
||||
});
|
||||
|
||||
it("不存在 id → null(模擬 404)", () => {
|
||||
expect(mockProfile("no-such")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidEmail", () => {
|
||||
it.each([
|
||||
["alice@corp.com", true],
|
||||
["a@b.co", true],
|
||||
["no-at-sign", false],
|
||||
["missing@domain", false],
|
||||
["@no-local.com", false],
|
||||
["", false],
|
||||
])("%s → %s", (email, expected) => {
|
||||
expect(isValidEmail(email)).toBe(expected);
|
||||
});
|
||||
});
|
||||
487
visionA-frontend/src/lib/api/model-sharing.ts
Normal file
487
visionA-frontend/src/lib/api/model-sharing.ts
Normal file
@ -0,0 +1,487 @@
|
||||
/**
|
||||
* Model Sharing API Client — visionA Cloud(模型共享 L 級新功能)
|
||||
*
|
||||
* 對齊:
|
||||
* - `docs/autoflow/04-architecture/api/api-model-sharing.md`(權威契約)
|
||||
* - `docs/autoflow/02-prd/features/feature-model-sharing.md`(需求背景)
|
||||
* - `docs/autoflow/03-design/feature-model-sharing-design.md`(UI 規格)
|
||||
*
|
||||
* 契約要點(以 API 契約為準,非設計規格的 shared 三態):
|
||||
* - visibility 維度:`private` / `tenant` / `public`(廣播式)
|
||||
* - model_shares 維度:點對點分享(與 visibility 正交),response 用 `shared_with_me` 標記
|
||||
* - my_access:`owner` / `editor` / `viewer` / `none`(有效權限,取最高)
|
||||
*
|
||||
* Endpoint:
|
||||
* 1. GET /api/models/library — 共享模型庫(cursor 分頁)
|
||||
* 2. GET /api/models/:id/profile — 公開版詳情(依身份裁剪)
|
||||
* 3. PATCH /api/models/:id/visibility — 設定公開對象(owner-only)
|
||||
* 4. GET/POST/DELETE /api/models/:id/shares — 點對點分享管理(owner-only)
|
||||
*
|
||||
* ⚠️ 安全:response 不含 owner email(契約 §4),前端不得自造 email 揭露。
|
||||
* shares 管理的 email 是 owner 自己輸入的授權目標,可顯示。
|
||||
*
|
||||
* 平行開發:本 client 對 API 契約 mock 開發,不依賴後端跑起來(見檔尾 mock 節)。
|
||||
*/
|
||||
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types — 對齊 api-model-sharing.md */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** 模型可見性(廣播維度)。 */
|
||||
export type ModelVisibility = "private" | "tenant" | "public";
|
||||
|
||||
/** 當前 user 對模型的有效權限(取最高)。 */
|
||||
export type ModelAccess = "owner" | "editor" | "viewer" | "none";
|
||||
|
||||
/** 排序欄位(契約 §1)。 */
|
||||
export type LibrarySort = "created_at" | "name" | "file_size";
|
||||
export type SortOrder = "asc" | "desc";
|
||||
|
||||
/** 共享庫列表項的 owner 資訊(契約:只揭露 id / name / is_me,不揭露 email)。 */
|
||||
export interface LibraryOwner {
|
||||
id: string;
|
||||
name: string;
|
||||
isMe: boolean;
|
||||
}
|
||||
|
||||
/** 共享庫單一模型項(契約 §1 response items)。 */
|
||||
export interface LibraryModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
targetChip: string;
|
||||
fileSize: number;
|
||||
source: "uploaded" | "converted" | "preset";
|
||||
status: "pending" | "ready";
|
||||
visibility: ModelVisibility;
|
||||
owner: LibraryOwner;
|
||||
/** 是否因 model_shares 命中(供 UI 標「共享給我」)。 */
|
||||
sharedWithMe: boolean;
|
||||
/** 當前 user 的有效權限。 */
|
||||
myAccess: ModelAccess;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 共享庫分頁結果(cursor 分頁契約 §1.3)。 */
|
||||
export interface LibraryPage {
|
||||
items: LibraryModel[];
|
||||
/** 下一頁游標(不透明);無下一頁時為 null。 */
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/** 共享庫查詢參數(契約 §1 query)。 */
|
||||
export interface LibraryQuery {
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
sort?: LibrarySort;
|
||||
order?: SortOrder;
|
||||
/** 搜尋關鍵字(比對 name + description)。 */
|
||||
q?: string;
|
||||
targetChip?: "kl520" | "kl720" | "kl630" | "kl730";
|
||||
source?: "uploaded" | "converted" | "preset";
|
||||
/** 僅過濾廣播類(public / tenant);private 不在共享庫語意內。 */
|
||||
visibility?: "public" | "tenant";
|
||||
/** true=只看我的、false=只看別人分享/公開給我的、不帶=全部。 */
|
||||
owned?: boolean;
|
||||
}
|
||||
|
||||
/** 模型 profile 頁(公開版詳情,契約 §2)。 */
|
||||
export interface ModelProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
targetChip: string;
|
||||
fileSize: number;
|
||||
source: "uploaded" | "converted" | "preset";
|
||||
status: "pending" | "ready";
|
||||
visibility: ModelVisibility;
|
||||
inputShape?: number[];
|
||||
classes?: string[];
|
||||
framework?: string;
|
||||
owner: LibraryOwner;
|
||||
myAccess: ModelAccess;
|
||||
/** my_access != none 時 true,前端據此決定是否顯示下載鈕。 */
|
||||
canDownload: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
uploadedAt?: string;
|
||||
}
|
||||
|
||||
/** 點對點分享單一授權對象(owner 檢視自己模型的授權清單)。 */
|
||||
export interface ModelShare {
|
||||
/** 被授權 user id。 */
|
||||
userId: string;
|
||||
/** 被授權 user 的顯示 email(owner 自己輸入的授權目標,可顯示)。 */
|
||||
email: string;
|
||||
/** 授權角色(P0 固定 viewer = 可檢視 + 下載)。 */
|
||||
role: "viewer" | "editor";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Error class */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 模型共享專用錯誤。UI 用 `error.code`(小寫)對應 i18n key(`models.sharing.error.<code>`)。
|
||||
*
|
||||
* code 來源(對齊契約各節錯誤碼,統一小寫):
|
||||
* - `not_found`(404,含「無可見性」防 enumeration)
|
||||
* - `forbidden`(403,非 owner 改 visibility / shares)
|
||||
* - `conflict`(409,未 ready 不允許公開)
|
||||
* - `validation_failed`(400,visibility 非法 / email 格式 / 無 org 設 tenant)
|
||||
* - `network_error` / `unknown`
|
||||
*/
|
||||
export class ModelSharingError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "ModelSharingError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
if (typeof Error.captureStackTrace === "function") {
|
||||
Error.captureStackTrace(this, ModelSharingError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 把底層 ApiError / 一般 Error 包成 ModelSharingError(code 統一小寫)。 */
|
||||
function wrapError(err: unknown): ModelSharingError {
|
||||
if (err instanceof ModelSharingError) return err;
|
||||
if (err instanceof ApiError) {
|
||||
return new ModelSharingError(err.status, err.code.toLowerCase(), err.message);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
const maybeCode = (err as unknown as { code?: unknown }).code;
|
||||
const code =
|
||||
typeof maybeCode === "string" ? maybeCode.toLowerCase() : "network_error";
|
||||
return new ModelSharingError(0, code, err.message);
|
||||
}
|
||||
return new ModelSharingError(0, "unknown", String(err));
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* snake_case ⇄ camelCase 正規化(後端契約用 snake_case) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
type Raw = Record<string, unknown>;
|
||||
|
||||
function asRaw(v: unknown): Raw {
|
||||
return (v ?? {}) as Raw;
|
||||
}
|
||||
|
||||
function pickStr(r: Raw, ...keys: string[]): string {
|
||||
for (const k of keys) {
|
||||
if (r[k] !== undefined && r[k] !== null) return String(r[k]);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function pickNum(r: Raw, ...keys: string[]): number {
|
||||
for (const k of keys) {
|
||||
if (r[k] !== undefined && r[k] !== null) return Number(r[k]);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function pickBool(r: Raw, ...keys: string[]): boolean {
|
||||
for (const k of keys) {
|
||||
if (r[k] !== undefined && r[k] !== null) return Boolean(r[k]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeVisibility(v: unknown): ModelVisibility {
|
||||
return v === "public" || v === "tenant" ? v : "private";
|
||||
}
|
||||
|
||||
function normalizeAccess(v: unknown): ModelAccess {
|
||||
return v === "owner" || v === "editor" || v === "viewer" ? v : "none";
|
||||
}
|
||||
|
||||
function normalizeOwner(raw: unknown): LibraryOwner {
|
||||
const r = asRaw(raw);
|
||||
return {
|
||||
id: pickStr(r, "id"),
|
||||
name: pickStr(r, "name"),
|
||||
isMe: pickBool(r, "is_me", "isMe"),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNumberArray(value: unknown): number[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const arr = value.map((v) => Number(v)).filter((n) => Number.isFinite(n));
|
||||
return arr.length > 0 ? arr : undefined;
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const arr = value
|
||||
.filter((v) => v !== null && v !== undefined)
|
||||
.map((v) => String(v));
|
||||
return arr.length > 0 ? arr : undefined;
|
||||
}
|
||||
|
||||
export function normalizeLibraryModel(raw: unknown): LibraryModel {
|
||||
const r = asRaw(raw);
|
||||
const rawChip = pickStr(r, "target_chip", "targetChip");
|
||||
const source = pickStr(r, "source") || "uploaded";
|
||||
const status = pickStr(r, "status") || "ready";
|
||||
return {
|
||||
id: pickStr(r, "id"),
|
||||
name: pickStr(r, "name"),
|
||||
description: r.description ? String(r.description) : undefined,
|
||||
targetChip: rawChip.toLowerCase(),
|
||||
fileSize: pickNum(r, "file_size", "fileSize"),
|
||||
source: source as LibraryModel["source"],
|
||||
status: status as LibraryModel["status"],
|
||||
visibility: normalizeVisibility(r.visibility),
|
||||
owner: normalizeOwner(r.owner),
|
||||
sharedWithMe: pickBool(r, "shared_with_me", "sharedWithMe"),
|
||||
myAccess: normalizeAccess(r.my_access ?? r.myAccess),
|
||||
createdAt: pickStr(r, "created_at", "createdAt"),
|
||||
updatedAt: pickStr(r, "updated_at", "updatedAt"),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeLibraryPage(raw: unknown): LibraryPage {
|
||||
const r = asRaw(raw);
|
||||
const items = Array.isArray(r.items) ? r.items.map(normalizeLibraryModel) : [];
|
||||
const nextCursorRaw = r.next_cursor ?? r.nextCursor;
|
||||
return {
|
||||
items,
|
||||
nextCursor: nextCursorRaw ? String(nextCursorRaw) : null,
|
||||
hasMore: pickBool(r, "has_more", "hasMore"),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeProfile(raw: unknown): ModelProfile {
|
||||
const r = asRaw(raw);
|
||||
const rawChip = pickStr(r, "target_chip", "targetChip");
|
||||
const source = pickStr(r, "source") || "uploaded";
|
||||
const status = pickStr(r, "status") || "ready";
|
||||
return {
|
||||
id: pickStr(r, "id"),
|
||||
name: pickStr(r, "name"),
|
||||
description: r.description ? String(r.description) : undefined,
|
||||
targetChip: rawChip.toLowerCase(),
|
||||
fileSize: pickNum(r, "file_size", "fileSize"),
|
||||
source: source as ModelProfile["source"],
|
||||
status: status as ModelProfile["status"],
|
||||
visibility: normalizeVisibility(r.visibility),
|
||||
inputShape: normalizeNumberArray(r.input_shape ?? r.inputShape),
|
||||
classes: normalizeStringArray(r.classes),
|
||||
framework: r.framework ? String(r.framework) : undefined,
|
||||
owner: normalizeOwner(r.owner),
|
||||
myAccess: normalizeAccess(r.my_access ?? r.myAccess),
|
||||
canDownload: pickBool(r, "can_download", "canDownload"),
|
||||
createdAt: pickStr(r, "created_at", "createdAt"),
|
||||
updatedAt: pickStr(r, "updated_at", "updatedAt"),
|
||||
uploadedAt: pickStr(r, "uploaded_at", "uploadedAt") || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeShare(raw: unknown): ModelShare {
|
||||
const r = asRaw(raw);
|
||||
const role = r.role === "editor" ? "editor" : "viewer";
|
||||
return {
|
||||
userId: pickStr(r, "user_id", "userId", "grantee_user_id"),
|
||||
email: pickStr(r, "email", "grantee_email"),
|
||||
role,
|
||||
createdAt: pickStr(r, "created_at", "createdAt"),
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 1. GET /api/models/library — 共享模型庫(cursor 分頁) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function buildLibraryQueryString(query: LibraryQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
if (query.cursor) params.set("cursor", query.cursor);
|
||||
if (query.limit !== undefined) params.set("limit", String(query.limit));
|
||||
if (query.sort) params.set("sort", query.sort);
|
||||
if (query.order) params.set("order", query.order);
|
||||
if (query.q) params.set("q", query.q);
|
||||
if (query.targetChip) params.set("target_chip", query.targetChip);
|
||||
if (query.source) params.set("source", query.source);
|
||||
if (query.visibility) params.set("visibility", query.visibility);
|
||||
if (query.owned !== undefined) params.set("owned", String(query.owned));
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 取共享模型庫的一頁。走 api.get wrapper(cookie session、envelope、ApiError mapping)。
|
||||
*
|
||||
* 後端未實作時(501)→ 回空頁而非丟錯,UI 走空狀態(與既有 fetchModels 對 501 的處理一致)。
|
||||
*
|
||||
* @throws {ModelSharingError} 400 validation_failed / 其他網路層錯誤
|
||||
*/
|
||||
export async function fetchLibrary(query: LibraryQuery = {}): Promise<LibraryPage> {
|
||||
try {
|
||||
const raw = await api.get<unknown>(
|
||||
`/api/models/library${buildLibraryQueryString(query)}`,
|
||||
);
|
||||
return normalizeLibraryPage(raw);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === "NOT_IMPLEMENTED") {
|
||||
return { items: [], nextCursor: null, hasMore: false };
|
||||
}
|
||||
throw wrapError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 2. GET /api/models/:id/profile — 公開版詳情 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 取模型 profile(公開版詳情)。權限檢查在後端;無可見性回 404(防 enumeration)。
|
||||
*
|
||||
* @throws {ModelSharingError} 404 not_found(不存在 or 無可見性)/ 其他
|
||||
*/
|
||||
export async function fetchProfile(modelId: string): Promise<ModelProfile> {
|
||||
if (!modelId) {
|
||||
throw new ModelSharingError(0, "validation_failed", "modelId is required");
|
||||
}
|
||||
try {
|
||||
const raw = await api.get<unknown>(
|
||||
`/api/models/${encodeURIComponent(modelId)}/profile`,
|
||||
);
|
||||
return normalizeProfile(raw);
|
||||
} catch (err) {
|
||||
throw wrapError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 3. PATCH /api/models/:id/visibility — 設定公開對象(owner-only) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface UpdateVisibilityResult {
|
||||
id: string;
|
||||
visibility: ModelVisibility;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 設定模型可見性。只有 owner 能改(後端把關)。
|
||||
*
|
||||
* @throws {ModelSharingError} 403 forbidden / 404 not_found /
|
||||
* 409 conflict(未 ready)/ 400 validation_failed(visibility 非法 / 無 org 設 tenant)
|
||||
*/
|
||||
export async function updateVisibility(
|
||||
modelId: string,
|
||||
visibility: ModelVisibility,
|
||||
): Promise<UpdateVisibilityResult> {
|
||||
if (!modelId) {
|
||||
throw new ModelSharingError(0, "validation_failed", "modelId is required");
|
||||
}
|
||||
try {
|
||||
const raw = await api.patch<unknown>(
|
||||
`/api/models/${encodeURIComponent(modelId)}/visibility`,
|
||||
{ visibility },
|
||||
);
|
||||
const r = asRaw(raw);
|
||||
return {
|
||||
id: pickStr(r, "id") || modelId,
|
||||
visibility: normalizeVisibility(r.visibility),
|
||||
updatedAt: pickStr(r, "updated_at", "updatedAt"),
|
||||
};
|
||||
} catch (err) {
|
||||
throw wrapError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 4. 點對點分享管理(owner-only)— shares CRUD */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 列出模型的授權對象清單(owner 檢視自己模型)。
|
||||
*
|
||||
* @throws {ModelSharingError} 403 forbidden / 404 not_found
|
||||
*/
|
||||
export async function fetchShares(modelId: string): Promise<ModelShare[]> {
|
||||
if (!modelId) {
|
||||
throw new ModelSharingError(0, "validation_failed", "modelId is required");
|
||||
}
|
||||
try {
|
||||
const raw = await api.get<unknown>(
|
||||
`/api/models/${encodeURIComponent(modelId)}/shares`,
|
||||
);
|
||||
const r = asRaw(raw);
|
||||
const list = Array.isArray(r.items)
|
||||
? r.items
|
||||
: Array.isArray(raw)
|
||||
? (raw as unknown[])
|
||||
: [];
|
||||
return list.map(normalizeShare);
|
||||
} catch (err) {
|
||||
throw wrapError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增授權對象(by email)。P0 固定 viewer 權限。
|
||||
*
|
||||
* @throws {ModelSharingError} 400 validation_failed(email 格式)/ 404 user_not_found /
|
||||
* 403 forbidden / 409 conflict(已在清單中)
|
||||
*/
|
||||
export async function addShare(
|
||||
modelId: string,
|
||||
email: string,
|
||||
): Promise<ModelShare> {
|
||||
if (!modelId) {
|
||||
throw new ModelSharingError(0, "validation_failed", "modelId is required");
|
||||
}
|
||||
if (!email) {
|
||||
throw new ModelSharingError(0, "validation_failed", "email is required");
|
||||
}
|
||||
try {
|
||||
const raw = await api.post<unknown>(
|
||||
`/api/models/${encodeURIComponent(modelId)}/shares`,
|
||||
{ email, role: "viewer" },
|
||||
);
|
||||
return normalizeShare(raw);
|
||||
} catch (err) {
|
||||
throw wrapError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除授權對象。
|
||||
*
|
||||
* @throws {ModelSharingError} 403 forbidden / 404 not_found
|
||||
*/
|
||||
export async function removeShare(
|
||||
modelId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
if (!modelId || !userId) {
|
||||
throw new ModelSharingError(0, "validation_failed", "modelId and userId are required");
|
||||
}
|
||||
try {
|
||||
await api.del(
|
||||
`/api/models/${encodeURIComponent(modelId)}/shares/${encodeURIComponent(userId)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
throw wrapError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Email 驗證(前端即時 UX;後端仍會驗) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** 寬鬆但實用的 email 格式驗證(前端即時回饋用;權威驗證在後端)。 */
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
|
||||
}
|
||||
196
visionA-frontend/src/lib/device-state.test.ts
Normal file
196
visionA-frontend/src/lib/device-state.test.ts
Normal file
@ -0,0 +1,196 @@
|
||||
/**
|
||||
* device-state 單元測試 — 三態運算真值表 + 排序 + filter
|
||||
*
|
||||
* 對齊:
|
||||
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5.2(真值表 ≥6 格)、§6(排序/filter)
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { DeviceSummary } from "@/stores/device-store";
|
||||
|
||||
import {
|
||||
applyDeviceListView,
|
||||
deriveTriState,
|
||||
filterDevices,
|
||||
isOnlineUnregistered,
|
||||
sortDevices,
|
||||
} from "./device-state";
|
||||
|
||||
/** 建一筆最小 DeviceSummary(只需 deriveTriState 用到的欄位可覆寫)。 */
|
||||
function makeDevice(overrides: Partial<DeviceSummary> = {}): DeviceSummary {
|
||||
return {
|
||||
id: "dev",
|
||||
name: "KL520",
|
||||
type: "kl520",
|
||||
status: "connected",
|
||||
remoteStatus: "online",
|
||||
registeredAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const REGISTERED_AT = "2026-08-02T10:00:00Z";
|
||||
|
||||
describe("deriveTriState — 真值表(連線軸 × 註冊軸)", () => {
|
||||
// TDD §5.2:online×registered / online×null / offline×registered / offline×null
|
||||
// + reconnecting / unknown(≥6 格)
|
||||
it("online + registeredAt 有值 → online-registered", () => {
|
||||
expect(
|
||||
deriveTriState({ remoteStatus: "online", registeredAt: REGISTERED_AT }),
|
||||
).toBe("online-registered");
|
||||
});
|
||||
|
||||
it("online + registeredAt null → online-unregistered(第三態)", () => {
|
||||
expect(deriveTriState({ remoteStatus: "online", registeredAt: null })).toBe(
|
||||
"online-unregistered",
|
||||
);
|
||||
});
|
||||
|
||||
it("online + registeredAt undefined → online-unregistered(缺欄等同未註冊)", () => {
|
||||
expect(deriveTriState({ remoteStatus: "online", registeredAt: undefined })).toBe(
|
||||
"online-unregistered",
|
||||
);
|
||||
});
|
||||
|
||||
it("offline + registeredAt 有值 → offline(離線不論註冊與否)", () => {
|
||||
expect(
|
||||
deriveTriState({ remoteStatus: "offline", registeredAt: REGISTERED_AT }),
|
||||
).toBe("offline");
|
||||
});
|
||||
|
||||
it("offline + registeredAt null → offline", () => {
|
||||
expect(deriveTriState({ remoteStatus: "offline", registeredAt: null })).toBe(
|
||||
"offline",
|
||||
);
|
||||
});
|
||||
|
||||
it("reconnecting → offline(非 online 一律歸 offline 態)", () => {
|
||||
expect(
|
||||
deriveTriState({ remoteStatus: "reconnecting", registeredAt: REGISTERED_AT }),
|
||||
).toBe("offline");
|
||||
});
|
||||
|
||||
it("unknown → offline", () => {
|
||||
expect(deriveTriState({ remoteStatus: "unknown", registeredAt: null })).toBe(
|
||||
"offline",
|
||||
);
|
||||
});
|
||||
|
||||
it("error → offline", () => {
|
||||
expect(
|
||||
deriveTriState({ remoteStatus: "error", registeredAt: REGISTERED_AT }),
|
||||
).toBe("offline");
|
||||
});
|
||||
|
||||
it("isOnlineUnregistered 僅在第三態為 true", () => {
|
||||
expect(
|
||||
isOnlineUnregistered({ remoteStatus: "online", registeredAt: null }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isOnlineUnregistered({ remoteStatus: "online", registeredAt: REGISTERED_AT }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isOnlineUnregistered({ remoteStatus: "offline", registeredAt: null }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterDevices — 依三態過濾", () => {
|
||||
const devices: DeviceSummary[] = [
|
||||
makeDevice({ id: "a", remoteStatus: "online", registeredAt: REGISTERED_AT }), // online-registered
|
||||
makeDevice({ id: "b", remoteStatus: "online", registeredAt: null }), // online-unregistered
|
||||
makeDevice({ id: "c", remoteStatus: "offline", registeredAt: null }), // offline
|
||||
makeDevice({ id: "d", remoteStatus: "reconnecting", registeredAt: REGISTERED_AT }), // offline
|
||||
];
|
||||
|
||||
it("all → 不過濾(回原陣列)", () => {
|
||||
expect(filterDevices(devices, "all")).toBe(devices);
|
||||
});
|
||||
|
||||
it("online-registered → 只留已連接已註冊", () => {
|
||||
expect(filterDevices(devices, "online-registered").map((d) => d.id)).toEqual([
|
||||
"a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("online-unregistered → 只留第三態", () => {
|
||||
expect(filterDevices(devices, "online-unregistered").map((d) => d.id)).toEqual([
|
||||
"b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("offline → 留所有非 online(含 reconnecting)", () => {
|
||||
expect(filterDevices(devices, "offline").map((d) => d.id)).toEqual(["c", "d"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortDevices — 三種排序鍵", () => {
|
||||
it("status:在線優先(online→reconnecting→unknown→offline→error),同狀態內比名稱", () => {
|
||||
const devices: DeviceSummary[] = [
|
||||
makeDevice({ id: "off", name: "Z", remoteStatus: "offline" }),
|
||||
makeDevice({ id: "on-b", name: "B", remoteStatus: "online" }),
|
||||
makeDevice({ id: "err", name: "A", remoteStatus: "error" }),
|
||||
makeDevice({ id: "on-a", name: "A", remoteStatus: "online" }),
|
||||
makeDevice({ id: "rec", name: "C", remoteStatus: "reconnecting" }),
|
||||
];
|
||||
expect(sortDevices(devices, "status").map((d) => d.id)).toEqual([
|
||||
"on-a", // online A
|
||||
"on-b", // online B
|
||||
"rec", // reconnecting
|
||||
"off", // offline
|
||||
"err", // error
|
||||
]);
|
||||
});
|
||||
|
||||
it("name:displayName(alias 優先)localeCompare A→Z", () => {
|
||||
const devices: DeviceSummary[] = [
|
||||
makeDevice({ id: "1", name: "Charlie" }),
|
||||
makeDevice({ id: "2", name: "Zoo", alias: "Apple" }), // alias 優先 → 排最前
|
||||
makeDevice({ id: "3", name: "Bravo" }),
|
||||
];
|
||||
expect(sortDevices(devices, "name").map((d) => d.id)).toEqual(["2", "3", "1"]);
|
||||
});
|
||||
|
||||
it("registeredAt:desc(新在前),null(未註冊)排最後", () => {
|
||||
const devices: DeviceSummary[] = [
|
||||
makeDevice({ id: "old", registeredAt: "2026-01-01T00:00:00Z" }),
|
||||
makeDevice({ id: "none", registeredAt: null }),
|
||||
makeDevice({ id: "new", registeredAt: "2026-08-01T00:00:00Z" }),
|
||||
];
|
||||
expect(sortDevices(devices, "registeredAt").map((d) => d.id)).toEqual([
|
||||
"new",
|
||||
"old",
|
||||
"none",
|
||||
]);
|
||||
});
|
||||
|
||||
it("不 mutate 輸入陣列", () => {
|
||||
const devices = [
|
||||
makeDevice({ id: "1", name: "B" }),
|
||||
makeDevice({ id: "2", name: "A" }),
|
||||
];
|
||||
const before = devices.map((d) => d.id);
|
||||
sortDevices(devices, "name");
|
||||
expect(devices.map((d) => d.id)).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyDeviceListView — 先 filter 再 sort", () => {
|
||||
it("filter 後再排序,順序正確", () => {
|
||||
const devices: DeviceSummary[] = [
|
||||
makeDevice({ id: "a", name: "Z", remoteStatus: "online", registeredAt: "2026-01-01T00:00:00Z" }),
|
||||
makeDevice({ id: "b", name: "A", remoteStatus: "online", registeredAt: "2026-08-01T00:00:00Z" }),
|
||||
makeDevice({ id: "c", name: "C", remoteStatus: "offline", registeredAt: null }),
|
||||
];
|
||||
// filter=online-registered(留 a、b)→ sort=name(A→Z:b、a)
|
||||
const result = applyDeviceListView(devices, "online-registered", "name");
|
||||
expect(result.map((d) => d.id)).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
it("filter 後 0 筆 → 回空陣列", () => {
|
||||
const devices: DeviceSummary[] = [
|
||||
makeDevice({ id: "a", remoteStatus: "offline", registeredAt: null }),
|
||||
];
|
||||
expect(applyDeviceListView(devices, "online-unregistered", "status")).toEqual([]);
|
||||
});
|
||||
});
|
||||
135
visionA-frontend/src/lib/device-state.ts
Normal file
135
visionA-frontend/src/lib/device-state.ts
Normal file
@ -0,0 +1,135 @@
|
||||
/**
|
||||
* device-state — 三態運算純函式(連線軸 × 註冊軸)
|
||||
*
|
||||
* 規格來源:
|
||||
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5.2(真值表)
|
||||
*
|
||||
* 三態 = 連線軸(remoteStatus)× 註冊軸(registeredAt)的組合:
|
||||
* | 態 | 條件 | 語意 |
|
||||
* | ----------------- | --------------------------------------- | ----------------------- |
|
||||
* | online-registered | remoteStatus === "online" 且 已註冊 | 正常可用的個人設備 |
|
||||
* | online-unregistered | remoteStatus === "online" 且 未註冊 | 插著、連線中但還沒註冊(第三態)|
|
||||
* | offline | remoteStatus !== "online" | 離線(不論註冊與否) |
|
||||
*
|
||||
* 純函式便於單元測試(真值表 6 格),且與 UI 解耦。
|
||||
*/
|
||||
|
||||
import type { DeviceSummary, RemoteStatus } from "@/stores/device-store";
|
||||
|
||||
/** 三態列舉:連線軸 × 註冊軸推導出的裝置狀態。 */
|
||||
export type DeviceTriState =
|
||||
| "online-registered"
|
||||
| "online-unregistered"
|
||||
| "offline";
|
||||
|
||||
/**
|
||||
* 由裝置的連線狀態與註冊時間推導三態。
|
||||
*
|
||||
* - online 且 registeredAt != null → "online-registered"
|
||||
* - online 且 registeredAt == null → "online-unregistered"
|
||||
* - 其餘(offline / reconnecting / error / unknown,不論註冊與否) → "offline"
|
||||
*
|
||||
* 註:TDD §5.2 真值表把「非 online 的連線狀態」全歸為 offline 態(分色沿用既有
|
||||
* RemoteDeviceBadge 各狀態色),註冊軸在非 online 時不影響三態判定。
|
||||
*/
|
||||
export function deriveTriState(
|
||||
d: Pick<DeviceSummary, "remoteStatus" | "registeredAt">,
|
||||
): DeviceTriState {
|
||||
if (d.remoteStatus !== "online") return "offline";
|
||||
return d.registeredAt != null ? "online-registered" : "online-unregistered";
|
||||
}
|
||||
|
||||
/** 便利判定:此裝置是否為「已連接未註冊」第三態(可被註冊)。 */
|
||||
export function isOnlineUnregistered(
|
||||
d: Pick<DeviceSummary, "remoteStatus" | "registeredAt">,
|
||||
): boolean {
|
||||
return deriveTriState(d) === "online-unregistered";
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 排序 + filter(TDD §6,client-side、不分頁) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** 排序鍵(TDD §6.2)。`status`=依連線狀態(預設,保留既有行為)。 */
|
||||
export type DeviceSortKey = "status" | "name" | "registeredAt";
|
||||
|
||||
/** Filter 選項(TDD §6.3,依三態)。`all`=不過濾(預設)。 */
|
||||
export type DeviceFilterKey =
|
||||
| "all"
|
||||
| "online-registered"
|
||||
| "offline"
|
||||
| "online-unregistered";
|
||||
|
||||
/**
|
||||
* 連線狀態排序權重(沿用 device-list 既有 STATUS_ORDER:在線優先)。
|
||||
* online → reconnecting → unknown → offline → error。
|
||||
*/
|
||||
const REMOTE_STATUS_ORDER: Record<RemoteStatus, number> = {
|
||||
online: 0,
|
||||
reconnecting: 1,
|
||||
unknown: 2,
|
||||
offline: 3,
|
||||
error: 4,
|
||||
};
|
||||
|
||||
/** 顯示名稱(alias 優先,對齊 DeviceCard 的 displayName 規則)。 */
|
||||
function displayName(d: DeviceSummary): string {
|
||||
return d.alias || d.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 依 filter 過濾裝置(純函式,用 deriveTriState 判定三態)。
|
||||
* `all` 回原陣列(不複製,呼叫端 sort 時才複製)。
|
||||
*/
|
||||
export function filterDevices(
|
||||
devices: DeviceSummary[],
|
||||
filter: DeviceFilterKey,
|
||||
): DeviceSummary[] {
|
||||
if (filter === "all") return devices;
|
||||
return devices.filter((d) => deriveTriState(d) === filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* 依排序鍵排序裝置(純函式,回新陣列、不 mutate 輸入)。
|
||||
*
|
||||
* - status:REMOTE_STATUS_ORDER(在線優先);同狀態內次比名稱(localeCompare)。
|
||||
* - name:displayName localeCompare,A→Z。
|
||||
* - registeredAt:desc(新註冊在前);null(未註冊)一律排最後。
|
||||
*/
|
||||
export function sortDevices(
|
||||
devices: DeviceSummary[],
|
||||
sortKey: DeviceSortKey,
|
||||
): DeviceSummary[] {
|
||||
const copy = [...devices];
|
||||
switch (sortKey) {
|
||||
case "name":
|
||||
return copy.sort((a, b) => displayName(a).localeCompare(displayName(b)));
|
||||
case "registeredAt":
|
||||
return copy.sort((a, b) => {
|
||||
const ra = a.registeredAt ?? null;
|
||||
const rb = b.registeredAt ?? null;
|
||||
// null(未註冊)排最後;兩者皆有值時比時間 desc(新在前)。
|
||||
if (ra == null && rb == null) return 0;
|
||||
if (ra == null) return 1;
|
||||
if (rb == null) return -1;
|
||||
return rb.localeCompare(ra);
|
||||
});
|
||||
case "status":
|
||||
default:
|
||||
return copy.sort((a, b) => {
|
||||
const diff =
|
||||
REMOTE_STATUS_ORDER[a.remoteStatus] - REMOTE_STATUS_ORDER[b.remoteStatus];
|
||||
// 同狀態內次比名稱,讓排序穩定可預期。
|
||||
return diff !== 0 ? diff : displayName(a).localeCompare(displayName(b));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 先 filter 再 sort(TDD §6.3:組合順序)。 */
|
||||
export function applyDeviceListView(
|
||||
devices: DeviceSummary[],
|
||||
filter: DeviceFilterKey,
|
||||
sortKey: DeviceSortKey,
|
||||
): DeviceSummary[] {
|
||||
return sortDevices(filterDevices(devices, filter), sortKey);
|
||||
}
|
||||
38
visionA-frontend/src/lib/format/relative-time.ts
Normal file
38
visionA-frontend/src/lib/format/relative-time.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 相對時間格式化(共用 util)
|
||||
*
|
||||
* 從 `components/cloud/remote-device-badge.tsx` 的 `formatRelativeTime` 抽出共用,
|
||||
* 讓「模型共享」的 owner 資訊列 / 共享時間沿用同一份規格與 i18n key,避免重複實作。
|
||||
*
|
||||
* 規格(components.md §10.3):
|
||||
* - < 60 秒 → 「剛剛」(remote.lastSeen.justNow)
|
||||
* - < 60 分 → 「X 分鐘前」(remote.lastSeen.minutesAgo)
|
||||
* - < 24 時 → 「X 小時前」(remote.lastSeen.hoursAgo)
|
||||
* - ≥ 24 時 → 絕對時間「MM/DD HH:mm」
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param isoString ISO 8601 時間字串(無法解析時回空字串)
|
||||
* @param nowMs 當前時間(ms)— 由 caller 傳入以便測試 deterministic
|
||||
* @param t i18n 翻譯函式(需含 remote.lastSeen.* key)
|
||||
*/
|
||||
export function formatRelativeTime(
|
||||
isoString: string,
|
||||
nowMs: number,
|
||||
t: (k: string) => string,
|
||||
): string {
|
||||
const ts = Date.parse(isoString);
|
||||
if (Number.isNaN(ts)) return "";
|
||||
const diffSec = Math.max(0, Math.floor((nowMs - ts) / 1000));
|
||||
if (diffSec < 60) return t("remote.lastSeen.justNow");
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return t("remote.lastSeen.minutesAgo").replace("{n}", String(diffMin));
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return t("remote.lastSeen.hoursAgo").replace("{n}", String(diffHour));
|
||||
const d = new Date(ts);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${mm}/${dd} ${hh}:${mi}`;
|
||||
}
|
||||
@ -180,6 +180,43 @@ export const en: Dictionary = {
|
||||
"devices.remove.error.NOT_FOUND": "This device no longer exists.",
|
||||
"devices.remove.error.unknown": "Something went wrong. Please try again.",
|
||||
|
||||
// ── Devices: tri-state (connection × registration) ──
|
||||
"devices.state.unregistered": "Unregistered",
|
||||
|
||||
// ── Devices: register / unregister ──
|
||||
"devices.register.action": "Register",
|
||||
"devices.register.pending": "Registering…",
|
||||
"devices.register.toast.success": "Device registered",
|
||||
"devices.register.error.title": "Couldn't register device",
|
||||
"devices.register.error.ALREADY_REGISTERED": "This device is already registered.",
|
||||
"devices.register.error.REPRESENTATIVE_DEVICE":
|
||||
"This kind of device can't be registered or unregistered.",
|
||||
"devices.register.error.FORBIDDEN": "You don't have permission to register this device.",
|
||||
"devices.register.error.NOT_FOUND": "This device no longer exists.",
|
||||
"devices.register.error.unknown": "Something went wrong. Please try again.",
|
||||
// Unregister (return to unregistered state) — NOT the same as removing/unpairing the device.
|
||||
"devices.unregister.action": "Unregister",
|
||||
"devices.unregister.pending": "Unregistering…",
|
||||
"devices.unregister.hint":
|
||||
"This returns the device to an unregistered state. It stays in your list and isn't removed.",
|
||||
"devices.unregister.toast.success": "Device unregistered",
|
||||
"devices.unregister.error.title": "Couldn't unregister device",
|
||||
|
||||
// ── Devices: sort + filter ──
|
||||
"devices.sort.label": "Sort by",
|
||||
"devices.sort.status": "Status",
|
||||
"devices.sort.name": "Name",
|
||||
"devices.sort.registeredAt": "Registered",
|
||||
"devices.filter.label": "Filter devices",
|
||||
"devices.filter.all": "All",
|
||||
"devices.filter.onlineRegistered": "Connected",
|
||||
"devices.filter.onlineUnregistered": "Connected, unregistered",
|
||||
"devices.filter.offline": "Disconnected",
|
||||
"devices.filter.empty.title": "No devices match this filter",
|
||||
"devices.filter.empty.description":
|
||||
"Try a different filter, or clear it to see all your devices.",
|
||||
"devices.filter.empty.action": "Clear filter",
|
||||
|
||||
// ── Devices: flash (load model to device) ──
|
||||
"devices.flash.flashModel": "Load model",
|
||||
"devices.flash.flashToDevice": "Load a model to this device",
|
||||
@ -309,6 +346,78 @@ export const en: Dictionary = {
|
||||
"models.download.error.busy": "A download is already in progress, please wait.",
|
||||
"models.download.error.unknown": "Download failed, please try again later.",
|
||||
|
||||
// ── Model Sharing ──
|
||||
// Shared model library list
|
||||
"models.library.title": "Shared Model Library",
|
||||
"models.library.subtitle": "Browse models you can access: yours, public ones, and those shared with you",
|
||||
"models.library.empty.title": "No shared models yet",
|
||||
"models.library.empty.description": "Models shared with you or made public will appear here",
|
||||
"models.library.empty.search.title": "No models match your criteria",
|
||||
"models.library.empty.search.description": "Try other keywords or clear the filters",
|
||||
"models.library.error.title": "Failed to load the library",
|
||||
"models.library.error.description": "Please try again later",
|
||||
"models.library.loadMore.retry": "Failed to load. Click to retry",
|
||||
"models.library.resultCount": "Found {n} models",
|
||||
"models.library.end": "All models shown",
|
||||
"models.library.link": "Shared library",
|
||||
// Search
|
||||
"models.search.placeholder": "Search model name…",
|
||||
"models.search.aria": "Search models",
|
||||
"models.search.clear": "Clear search",
|
||||
"models.search.clearAll": "Clear all filters",
|
||||
// Filters
|
||||
"models.filters.owned": "Ownership",
|
||||
"models.filters.owned.all": "All",
|
||||
"models.filters.owned.mine": "My models",
|
||||
"models.filters.owned.shared": "Shared with me",
|
||||
"models.filters.visibility": "Visibility",
|
||||
// Sort
|
||||
"models.sort.label": "Sort",
|
||||
"models.sort.createdAt": "Newest",
|
||||
"models.sort.name": "Name",
|
||||
"models.sort.fileSize": "File size",
|
||||
// Visibility badge (three states + shared)
|
||||
"models.visibility.badge.private": "Private",
|
||||
"models.visibility.badge.public": "Public",
|
||||
"models.visibility.badge.tenant": "Same tenant",
|
||||
"models.visibility.badge.sharedWithMe": "Shared with me",
|
||||
"models.visibility.badge.sharedCount": "{n} people",
|
||||
// Card owner menu
|
||||
"models.card.menu.aria": "Model actions menu",
|
||||
// Receiver info row (contract does not expose email, use name)
|
||||
"models.sharedByName": "Shared by {name}",
|
||||
"models.ownerBar.aria": "Model owner info",
|
||||
// Visibility dialog
|
||||
"models.visibility.title": "Visibility",
|
||||
"models.visibility.question": "Who can access this model?",
|
||||
"models.visibility.private": "Private",
|
||||
"models.visibility.private.desc": "Only you",
|
||||
"models.visibility.public": "Public",
|
||||
"models.visibility.public.desc": "All visionA users",
|
||||
"models.visibility.tenant": "Same tenant",
|
||||
"models.visibility.tenant.desc": "Members of your organization",
|
||||
"models.visibility.sharedPeopleTitle": "Specific people (additionally shared)",
|
||||
"models.visibility.addEmail": "Add by email",
|
||||
"models.visibility.addButton": "Add",
|
||||
"models.visibility.noShares": "Not shared with anyone yet",
|
||||
"models.visibility.permissionViewDownload": "View + download",
|
||||
"models.visibility.removeShare": "Remove {email}",
|
||||
"models.visibility.publicWarning": "Once public, all visionA users can view and download this model",
|
||||
"models.visibility.saveButton": "Save changes",
|
||||
"models.visibility.saved": "Visibility updated",
|
||||
"models.visibility.saveFailed": "Failed to save, please retry",
|
||||
"models.visibility.notReady": "Model is not ready and cannot be made public",
|
||||
"models.visibility.emailInvalid": "Invalid email format",
|
||||
"models.visibility.emailDuplicate": "Already in the list",
|
||||
"models.visibility.userNotFound": "User {email} not found",
|
||||
"models.visibility.revokeConfirm": "Setting to private revokes access for shared users. Continue?",
|
||||
// Profile page
|
||||
"models.profile.notFound.title": "Model not found or no access",
|
||||
"models.profile.notFound.description": "This model does not exist, is not public, or was not shared with you",
|
||||
"models.profile.backToLibrary": "Back to shared library",
|
||||
// Generic sharing error
|
||||
"models.sharing.error.generic": "Operation failed, please try again later",
|
||||
|
||||
// ── Workspace ──
|
||||
"workspace.title": "Workspace",
|
||||
"workspace.subtitle": "Select an online device to start inference",
|
||||
|
||||
@ -181,6 +181,41 @@ export const zhHant: Dictionary = {
|
||||
"devices.remove.error.NOT_FOUND": "此裝置已不存在",
|
||||
"devices.remove.error.unknown": "發生錯誤,請稍後再試",
|
||||
|
||||
// ── Devices: 三態(連線 × 註冊) ──
|
||||
"devices.state.unregistered": "未註冊",
|
||||
|
||||
// ── Devices: 註冊 / 取消註冊 ──
|
||||
"devices.register.action": "註冊",
|
||||
"devices.register.pending": "註冊中…",
|
||||
"devices.register.toast.success": "已註冊裝置",
|
||||
"devices.register.error.title": "註冊裝置失敗",
|
||||
"devices.register.error.ALREADY_REGISTERED": "此裝置已註冊",
|
||||
"devices.register.error.REPRESENTATIVE_DEVICE": "這類裝置無法註冊或取消註冊",
|
||||
"devices.register.error.FORBIDDEN": "你沒有權限註冊此裝置",
|
||||
"devices.register.error.NOT_FOUND": "此裝置已不存在",
|
||||
"devices.register.error.unknown": "發生錯誤,請稍後再試",
|
||||
// 取消註冊(退回未註冊態)— 與「移除裝置(解除配對)」不同,不會刪掉裝置。
|
||||
"devices.unregister.action": "取消註冊",
|
||||
"devices.unregister.pending": "取消註冊中…",
|
||||
"devices.unregister.hint":
|
||||
"這會把裝置退回未註冊狀態,裝置仍保留在清單中,不會被移除。",
|
||||
"devices.unregister.toast.success": "已取消註冊",
|
||||
"devices.unregister.error.title": "取消註冊失敗",
|
||||
|
||||
// ── Devices: 排序 + 篩選 ──
|
||||
"devices.sort.label": "排序方式",
|
||||
"devices.sort.status": "狀態",
|
||||
"devices.sort.name": "名稱",
|
||||
"devices.sort.registeredAt": "註冊時間",
|
||||
"devices.filter.label": "篩選裝置",
|
||||
"devices.filter.all": "全部",
|
||||
"devices.filter.onlineRegistered": "已連接",
|
||||
"devices.filter.onlineUnregistered": "已連接未註冊",
|
||||
"devices.filter.offline": "未連接",
|
||||
"devices.filter.empty.title": "沒有符合此篩選條件的裝置",
|
||||
"devices.filter.empty.description": "試試其他篩選條件,或清除篩選以顯示所有裝置。",
|
||||
"devices.filter.empty.action": "清除篩選",
|
||||
|
||||
// ── Devices: flash(載入模型到裝置) ──
|
||||
"devices.flash.flashModel": "載入模型",
|
||||
"devices.flash.flashToDevice": "載入模型到此裝置",
|
||||
@ -301,6 +336,78 @@ export const zhHant: Dictionary = {
|
||||
"models.download.error.busy": "已有下載進行中,請稍候",
|
||||
"models.download.error.unknown": "下載失敗,請稍後再試",
|
||||
|
||||
// ── 模型共享(Model Sharing)──
|
||||
// 共享模型庫列表
|
||||
"models.library.title": "共享模型庫",
|
||||
"models.library.subtitle": "瀏覽你可存取的模型:你的、公開的、以及別人分享給你的",
|
||||
"models.library.empty.title": "還沒有可存取的共享模型",
|
||||
"models.library.empty.description": "當同事把模型分享給你、或有公開模型時,會出現在這裡",
|
||||
"models.library.empty.search.title": "找不到符合條件的模型",
|
||||
"models.library.empty.search.description": "試試其他關鍵字或清除篩選條件",
|
||||
"models.library.error.title": "載入模型庫失敗",
|
||||
"models.library.error.description": "請稍後再試",
|
||||
"models.library.loadMore.retry": "載入失敗,點擊重試",
|
||||
"models.library.resultCount": "找到 {n} 個模型",
|
||||
"models.library.end": "已顯示全部模型",
|
||||
"models.library.link": "共享模型庫",
|
||||
// 搜尋
|
||||
"models.search.placeholder": "搜尋模型名稱…",
|
||||
"models.search.aria": "搜尋模型",
|
||||
"models.search.clear": "清除搜尋",
|
||||
"models.search.clearAll": "清除所有篩選",
|
||||
// filter
|
||||
"models.filters.owned": "擁有關係",
|
||||
"models.filters.owned.all": "全部",
|
||||
"models.filters.owned.mine": "我的模型",
|
||||
"models.filters.owned.shared": "共享給我",
|
||||
"models.filters.visibility": "可見性",
|
||||
// 排序
|
||||
"models.sort.label": "排序",
|
||||
"models.sort.createdAt": "最新建立",
|
||||
"models.sort.name": "名稱",
|
||||
"models.sort.fileSize": "檔案大小",
|
||||
// visibility badge(三態 + 共享)
|
||||
"models.visibility.badge.private": "私有",
|
||||
"models.visibility.badge.public": "公開",
|
||||
"models.visibility.badge.tenant": "同租戶",
|
||||
"models.visibility.badge.sharedWithMe": "共享給我",
|
||||
"models.visibility.badge.sharedCount": "{n} 人",
|
||||
// 卡片 owner 選單
|
||||
"models.card.menu.aria": "模型操作選單",
|
||||
// receiver 資訊列(契約不揭露 email,用名稱)
|
||||
"models.sharedByName": "由 {name} 共享",
|
||||
"models.ownerBar.aria": "模型擁有者資訊",
|
||||
// 公開設定 Dialog
|
||||
"models.visibility.title": "公開設定",
|
||||
"models.visibility.question": "誰可以看到並使用這個模型?",
|
||||
"models.visibility.private": "私有",
|
||||
"models.visibility.private.desc": "只有你自己",
|
||||
"models.visibility.public": "公開",
|
||||
"models.visibility.public.desc": "所有 visionA 使用者",
|
||||
"models.visibility.tenant": "同租戶",
|
||||
"models.visibility.tenant.desc": "與你同組織的成員",
|
||||
"models.visibility.sharedPeopleTitle": "指定對象(額外分享給特定人)",
|
||||
"models.visibility.addEmail": "輸入 email 加入",
|
||||
"models.visibility.addButton": "加入",
|
||||
"models.visibility.noShares": "尚未分享給任何人",
|
||||
"models.visibility.permissionViewDownload": "可檢視 + 下載",
|
||||
"models.visibility.removeShare": "移除 {email}",
|
||||
"models.visibility.publicWarning": "公開後,所有 visionA 使用者都能檢視並下載此模型",
|
||||
"models.visibility.saveButton": "儲存變更",
|
||||
"models.visibility.saved": "已更新公開設定",
|
||||
"models.visibility.saveFailed": "儲存失敗,請重試",
|
||||
"models.visibility.notReady": "模型尚未就緒,無法公開",
|
||||
"models.visibility.emailInvalid": "Email 格式不正確",
|
||||
"models.visibility.emailDuplicate": "已在清單中",
|
||||
"models.visibility.userNotFound": "找不到使用者 {email}",
|
||||
"models.visibility.revokeConfirm": "改為私有後,已分享的對象將無法再存取,確定要繼續嗎?",
|
||||
// profile 頁
|
||||
"models.profile.notFound.title": "找不到模型或沒有存取權",
|
||||
"models.profile.notFound.description": "這個模型不存在、未公開,或未分享給你",
|
||||
"models.profile.backToLibrary": "返回共享模型庫",
|
||||
// 通用共享錯誤
|
||||
"models.sharing.error.generic": "操作失敗,請稍後再試",
|
||||
|
||||
// ── Workspace ──
|
||||
"workspace.title": "推論工作區",
|
||||
"workspace.subtitle": "選擇已線上的裝置開始推論",
|
||||
|
||||
@ -23,6 +23,7 @@ beforeEach(() => {
|
||||
connectingId: null,
|
||||
disconnectingId: null,
|
||||
unpairingId: null,
|
||||
registeringId: null,
|
||||
error: null,
|
||||
});
|
||||
// OF2:api.ts 不再需要 token getter(cookie session 由瀏覽器自動帶)
|
||||
@ -412,3 +413,253 @@ describe("useDeviceStore.unpairDevice", () => {
|
||||
expect(useDeviceStore.getState().unpairingId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 註冊軸:normalizeDevice registeredAt + register / unregister actions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("useDeviceStore — registeredAt 正規化(TDD §5.1)", () => {
|
||||
it("registered_at(snake)/ registeredAt(camel)有值 → 正確帶入;缺欄 → null", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: [
|
||||
// snake_case(後端實際形狀)
|
||||
{
|
||||
id: "dev-1",
|
||||
name: "A",
|
||||
type: "kl520",
|
||||
status: "connected",
|
||||
registered_at: "2026-08-02T10:00:00Z",
|
||||
},
|
||||
// camelCase 容錯
|
||||
{
|
||||
id: "dev-2",
|
||||
name: "B",
|
||||
type: "kl520",
|
||||
status: "connected",
|
||||
registeredAt: "2026-08-01T00:00:00Z",
|
||||
},
|
||||
// 缺欄位(未註冊 / 舊資料)→ null
|
||||
{ id: "dev-3", name: "C", type: "kl520", status: "connected" },
|
||||
// 明確 null → null
|
||||
{
|
||||
id: "dev-4",
|
||||
name: "D",
|
||||
type: "kl520",
|
||||
status: "connected",
|
||||
registered_at: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await useDeviceStore.getState().fetchDevices();
|
||||
const { devices } = useDeviceStore.getState();
|
||||
expect(devices[0]?.registeredAt).toBe("2026-08-02T10:00:00Z");
|
||||
expect(devices[1]?.registeredAt).toBe("2026-08-01T00:00:00Z");
|
||||
expect(devices[2]?.registeredAt).toBeNull();
|
||||
expect(devices[3]?.registeredAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDeviceStore.registerDevice", () => {
|
||||
const unregistered = {
|
||||
id: "dev-1",
|
||||
name: "KL520",
|
||||
type: "kl520",
|
||||
status: "connected" as const,
|
||||
remoteStatus: "online" as const,
|
||||
registeredAt: null,
|
||||
};
|
||||
|
||||
it("成功時打對 register endpoint(UUID)、就地更新 registeredAt、回 { ok:true }", async () => {
|
||||
useDeviceStore.setState({
|
||||
devices: [unregistered, { ...unregistered, id: "dev-2" }],
|
||||
selectedDevice: { ...unregistered },
|
||||
});
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
id: "dev-1",
|
||||
name: "KL520",
|
||||
device_type: "kl520",
|
||||
status: "connected",
|
||||
remote_status: "online",
|
||||
registered_at: "2026-08-02T10:00:00Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await useDeviceStore.getState().registerDevice("dev-1");
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
const calledUrl = String(fetchSpy.mock.calls[0]?.[0]);
|
||||
expect(calledUrl).toContain("/api/devices/dev-1/register");
|
||||
expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ method: "POST" });
|
||||
|
||||
const state = useDeviceStore.getState();
|
||||
// 就地更新該筆 registeredAt(不移除 list)
|
||||
expect(state.devices.find((d) => d.id === "dev-1")?.registeredAt).toBe(
|
||||
"2026-08-02T10:00:00Z",
|
||||
);
|
||||
// 其他裝置不受影響
|
||||
expect(state.devices.find((d) => d.id === "dev-2")?.registeredAt).toBeNull();
|
||||
// selectedDevice 同步更新
|
||||
expect(state.selectedDevice?.registeredAt).toBe("2026-08-02T10:00:00Z");
|
||||
expect(state.registeringId).toBeNull();
|
||||
});
|
||||
|
||||
it("就地 merge 只覆寫 registeredAt,不把本地既有欄位清成 null(後端 omitempty 防禦)", async () => {
|
||||
useDeviceStore.setState({
|
||||
devices: [{ ...unregistered, firmwareVersion: "2.3.1" }],
|
||||
});
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
// 後端回應缺 firmware_version(omitempty)
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
id: "dev-1",
|
||||
name: "KL520",
|
||||
status: "connected",
|
||||
registered_at: "2026-08-02T10:00:00Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await useDeviceStore.getState().registerDevice("dev-1");
|
||||
const d = useDeviceStore.getState().devices[0];
|
||||
expect(d?.registeredAt).toBe("2026-08-02T10:00:00Z");
|
||||
// 本地既有 firmwareVersion 不被覆寫成 null
|
||||
expect(d?.firmwareVersion).toBe("2.3.1");
|
||||
});
|
||||
|
||||
it("呼叫期間 registeringId 設為該 id(loading 態)", async () => {
|
||||
let observed: string | null = "not-set";
|
||||
vi.spyOn(globalThis, "fetch").mockImplementationOnce(async () => {
|
||||
observed = useDeviceStore.getState().registeringId;
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
data: { id: "dev-1", registered_at: "2026-08-02T10:00:00Z" },
|
||||
});
|
||||
});
|
||||
|
||||
await useDeviceStore.getState().registerDevice("dev-1");
|
||||
expect(observed).toBe("dev-1");
|
||||
expect(useDeviceStore.getState().registeringId).toBeNull();
|
||||
});
|
||||
|
||||
it("409 ALREADY_REGISTERED → 回 { ok:false, code:'ALREADY_REGISTERED' },不改 list", async () => {
|
||||
useDeviceStore.setState({ devices: [unregistered] });
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
success: false,
|
||||
error: { code: "ALREADY_REGISTERED", message: "device already registered" },
|
||||
},
|
||||
409,
|
||||
),
|
||||
);
|
||||
|
||||
const result = await useDeviceStore.getState().registerDevice("dev-1");
|
||||
expect(result).toMatchObject({ ok: false, code: "ALREADY_REGISTERED" });
|
||||
// list 不變(registeredAt 仍 null)
|
||||
expect(useDeviceStore.getState().devices[0]?.registeredAt).toBeNull();
|
||||
expect(useDeviceStore.getState().registeringId).toBeNull();
|
||||
});
|
||||
|
||||
it("409 REPRESENTATIVE_DEVICE(representative)→ 回 { ok:false, code:'REPRESENTATIVE_DEVICE' }", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{
|
||||
success: false,
|
||||
error: { code: "REPRESENTATIVE_DEVICE", message: "representative" },
|
||||
},
|
||||
409,
|
||||
),
|
||||
);
|
||||
const result = await useDeviceStore.getState().registerDevice("dev-1");
|
||||
expect(result).toMatchObject({ ok: false, code: "REPRESENTATIVE_DEVICE" });
|
||||
});
|
||||
|
||||
it("403 FORBIDDEN(非 owner)→ 回 { ok:false, code:'FORBIDDEN' }", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ success: false, error: { code: "FORBIDDEN", message: "not owner" } },
|
||||
403,
|
||||
),
|
||||
);
|
||||
const result = await useDeviceStore.getState().registerDevice("dev-1");
|
||||
expect(result).toMatchObject({ ok: false, code: "FORBIDDEN" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDeviceStore.unregisterDevice", () => {
|
||||
const registered = {
|
||||
id: "dev-1",
|
||||
name: "KL520",
|
||||
type: "kl520",
|
||||
status: "connected" as const,
|
||||
remoteStatus: "online" as const,
|
||||
registeredAt: "2026-08-02T10:00:00Z",
|
||||
};
|
||||
|
||||
it("成功時打對 unregister endpoint、清 registeredAt、**保留 list**(不移除)", async () => {
|
||||
useDeviceStore.setState({
|
||||
devices: [registered, { ...registered, id: "dev-2" }],
|
||||
selectedDevice: { ...registered },
|
||||
});
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: { id: "dev-1", name: "KL520", status: "connected", registered_at: null },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await useDeviceStore.getState().unregisterDevice("dev-1");
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
const calledUrl = String(fetchSpy.mock.calls[0]?.[0]);
|
||||
expect(calledUrl).toContain("/api/devices/dev-1/unregister");
|
||||
expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ method: "POST" });
|
||||
|
||||
const state = useDeviceStore.getState();
|
||||
// 關鍵:device 仍在 list(與 unpair 的差異),只是 registeredAt 清 null
|
||||
expect(state.devices.map((d) => d.id)).toEqual(["dev-1", "dev-2"]);
|
||||
expect(state.devices.find((d) => d.id === "dev-1")?.registeredAt).toBeNull();
|
||||
expect(state.selectedDevice?.registeredAt).toBeNull();
|
||||
expect(state.registeringId).toBeNull();
|
||||
});
|
||||
|
||||
it("冪等:已未註冊再 unregister(後端回 200 null)→ ok:true,list 保留", async () => {
|
||||
useDeviceStore.setState({
|
||||
devices: [{ ...registered, registeredAt: null }],
|
||||
});
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
jsonResponse({ success: true, data: { id: "dev-1", registered_at: null } }),
|
||||
);
|
||||
|
||||
const result = await useDeviceStore.getState().unregisterDevice("dev-1");
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(useDeviceStore.getState().devices.map((d) => d.id)).toEqual(["dev-1"]);
|
||||
});
|
||||
|
||||
it("403 FORBIDDEN → 回 { ok:false, code:'FORBIDDEN' },list 不變", async () => {
|
||||
useDeviceStore.setState({ devices: [registered] });
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ success: false, error: { code: "FORBIDDEN", message: "not owner" } },
|
||||
403,
|
||||
),
|
||||
);
|
||||
|
||||
const result = await useDeviceStore.getState().unregisterDevice("dev-1");
|
||||
expect(result).toMatchObject({ ok: false, code: "FORBIDDEN" });
|
||||
// 失敗時 registeredAt 不變
|
||||
expect(useDeviceStore.getState().devices[0]?.registeredAt).toBe(
|
||||
"2026-08-02T10:00:00Z",
|
||||
);
|
||||
expect(useDeviceStore.getState().registeringId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -89,6 +89,13 @@ export interface DeviceSummary {
|
||||
status: DeviceHardwareStatus;
|
||||
/** 遠端 tunnel 狀態(flow-offline-handling.md §2 新增) */
|
||||
remoteStatus: RemoteStatus;
|
||||
/**
|
||||
* 註冊時間(ISO 8601)— 註冊軸的真實來源(feature-device-mgmt-tdd.md §5)。
|
||||
* 後端 JSON key 為 `registered_at`(omitempty):null / 缺欄位 = 未註冊。
|
||||
* 三態運算(lib/device-state.ts deriveTriState)以「online 且 registeredAt != null」
|
||||
* 判為「已連接(已註冊在線)」;online 且 null → 第三態「已連接未註冊」。
|
||||
*/
|
||||
registeredAt?: string | null;
|
||||
/** ISO 8601,最後心跳時間 */
|
||||
lastSeenAt?: string | null;
|
||||
firmwareVersion?: string | null;
|
||||
@ -152,6 +159,9 @@ function normalizeDevice(raw: unknown): Device {
|
||||
status: coerceHardwareStatus(pick<string>("status")),
|
||||
remoteStatus:
|
||||
tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"),
|
||||
// 註冊軸(TDD §5.1):後端回 registered_at(omitempty)— 缺欄 / null 皆視為未註冊。
|
||||
// 沿用既有 pick snake/camel 相容範式;不做時間格式驗證(後端保證 ISO 8601)。
|
||||
registeredAt: pick<string>("registered_at", "registeredAt") ?? null,
|
||||
lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null,
|
||||
firmwareVersion:
|
||||
pick<string>("firmware_version", "firmwareVersion") ?? null,
|
||||
@ -179,6 +189,20 @@ export type UnpairResult =
|
||||
| { ok: true }
|
||||
| { ok: false; code: string; message: string };
|
||||
|
||||
/**
|
||||
* register / unregister action 的回傳。
|
||||
*
|
||||
* 與 unpair 同樣採「回 code 而非 boolean」的範式,讓 UI 對不同錯誤分流顯示 toast:
|
||||
* - register:409 `ALREADY_REGISTERED`(已註冊)、409 `REPRESENTATIVE_DEVICE`(representative)、
|
||||
* 403 `FORBIDDEN`(非 owner)、404 `NOT_FOUND` 等(api-device-mgmt.md §3)。
|
||||
* - unregister:契約上冪等(已未註冊回 200),主要錯誤為 403 / 404 / representative(REPRESENTATIVE_DEVICE)。
|
||||
*
|
||||
* 成功時 store 已就地更新該筆 device 的 registeredAt(避免 refetch 延遲,比照 unpair 就地移除範式)。
|
||||
*/
|
||||
export type RegisterResult =
|
||||
| { ok: true }
|
||||
| { ok: false; code: string; message: string };
|
||||
|
||||
interface DeviceState {
|
||||
devices: DeviceSummary[];
|
||||
selectedDevice: Device | null;
|
||||
@ -188,6 +212,8 @@ interface DeviceState {
|
||||
disconnectingId: string | null;
|
||||
/** 移除(unpair)中的裝置 id(UI 顯示 button spinner / disable 確認鈕);不使用就是 null */
|
||||
unpairingId: string | null;
|
||||
/** 註冊 / 取消註冊進行中的裝置 id(UI 顯示 button spinner);不使用就是 null */
|
||||
registeringId: string | null;
|
||||
error: string | null;
|
||||
|
||||
/** 呼叫 `GET /api/devices` */
|
||||
@ -207,6 +233,19 @@ interface DeviceState {
|
||||
disconnectDevice: (serialNumber: string) => Promise<boolean>;
|
||||
/** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token) */
|
||||
unpairDevice: (id: string) => Promise<UnpairResult>;
|
||||
/**
|
||||
* 呼叫 `POST /api/devices/:id/register`(UUID 識別,純雲端 DB 操作)。
|
||||
* 把裝置由「未註冊」翻成「已註冊」(registered_at NULL → now())。
|
||||
* 成功後就地更新該筆 registeredAt(避免 refetch 延遲)。
|
||||
* ⚠️ 與 connect 不同(connect 用 serial 路由);register 用 UUID(ADR-018 FE-A:DB 操作用 UUID)。
|
||||
*/
|
||||
registerDevice: (id: string) => Promise<RegisterResult>;
|
||||
/**
|
||||
* 呼叫 `POST /api/devices/:id/unregister`(UUID 識別)。
|
||||
* 把裝置退回「未註冊」(registered_at → NULL),**保留裝置列**(不軟刪、不撤 token)。
|
||||
* ⚠️ 取消註冊 ≠ 移除裝置(unpair):unregister 只清 registeredAt、device 仍在清單顯示為未註冊。
|
||||
*/
|
||||
unregisterDevice: (id: string) => Promise<RegisterResult>;
|
||||
/** 測試 / 雛形用:直接塞 list */
|
||||
_setDevices: (devices: DeviceSummary[]) => void;
|
||||
/** 測試 / 雛形用:直接塞 selected */
|
||||
@ -220,6 +259,7 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
|
||||
connectingId: null,
|
||||
disconnectingId: null,
|
||||
unpairingId: null,
|
||||
registeringId: null,
|
||||
error: null,
|
||||
|
||||
fetchDevices: async () => {
|
||||
@ -317,6 +357,65 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
|
||||
}
|
||||
},
|
||||
|
||||
registerDevice: async (id) => {
|
||||
set({ registeringId: id, error: null });
|
||||
try {
|
||||
// 契約:POST /api/devices/:id/register → 回更新後的 DeviceListItem(registered_at 非 null)。
|
||||
// api.post 已 unwrap envelope 的 data;normalizeDevice 讀出 registeredAt。
|
||||
const raw = await api.post<unknown>(
|
||||
`/api/devices/${encodeURIComponent(id)}/register`,
|
||||
);
|
||||
const updated = normalizeDevice(raw);
|
||||
// 就地更新該筆 registeredAt(避免 refetch 延遲,比照 unpair 就地移除範式)。
|
||||
// 後端回應可能缺部分欄位(omitempty)→ 只 merge registeredAt,其餘沿用本地既有值,
|
||||
// 避免把本地已知欄位(如 firmwareVersion)覆寫成 null。
|
||||
set((state) => ({
|
||||
devices: state.devices.map((d) =>
|
||||
d.id === id ? { ...d, registeredAt: updated.registeredAt } : d,
|
||||
),
|
||||
selectedDevice:
|
||||
state.selectedDevice?.id === id
|
||||
? { ...state.selectedDevice, registeredAt: updated.registeredAt }
|
||||
: state.selectedDevice,
|
||||
registeringId: null,
|
||||
}));
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const code = err instanceof ApiError ? err.code : "unknown";
|
||||
set({ registeringId: null, error: message });
|
||||
return { ok: false, code, message };
|
||||
}
|
||||
},
|
||||
|
||||
unregisterDevice: async (id) => {
|
||||
set({ registeringId: id, error: null });
|
||||
try {
|
||||
// 契約:POST /api/devices/:id/unregister → registered_at → null,**保留裝置列**(不軟刪)。
|
||||
// 冪等:已未註冊也回 200。回更新後 DeviceListItem(registered_at=null)。
|
||||
await api.post<unknown>(
|
||||
`/api/devices/${encodeURIComponent(id)}/unregister`,
|
||||
);
|
||||
// 就地把該筆 registeredAt 清成 null(device 仍留在 list,不移除——與 unpair 的關鍵差異)。
|
||||
set((state) => ({
|
||||
devices: state.devices.map((d) =>
|
||||
d.id === id ? { ...d, registeredAt: null } : d,
|
||||
),
|
||||
selectedDevice:
|
||||
state.selectedDevice?.id === id
|
||||
? { ...state.selectedDevice, registeredAt: null }
|
||||
: state.selectedDevice,
|
||||
registeringId: null,
|
||||
}));
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const code = err instanceof ApiError ? err.code : "unknown";
|
||||
set({ registeringId: null, error: message });
|
||||
return { ok: false, code, message };
|
||||
}
|
||||
},
|
||||
|
||||
_setDevices: (devices) => set({ devices }),
|
||||
_setSelected: (selectedDevice) => set({ selectedDevice }),
|
||||
}));
|
||||
|
||||
183
visionA-frontend/src/stores/model-sharing-store.test.ts
Normal file
183
visionA-frontend/src/stores/model-sharing-store.test.ts
Normal file
@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Model Sharing Store 測試(mock 模式,deterministic)
|
||||
*
|
||||
* 覆蓋:
|
||||
* - loadFirstPage:載入首頁、設 cursor / hasMore
|
||||
* - loadMore:append 下一頁、不重複、到底 hasMore=false
|
||||
* - loadMore 重入防護:無 cursor / 載入中不觸發
|
||||
* - setFilters:重置分頁並重新載入
|
||||
* - loadProfile:mock 命中 / 404
|
||||
* - updateVisibility / addShare / removeShare(樂觀更新)
|
||||
* - filtersToQuery:UI filters → API query 映射
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_LIBRARY_FILTERS,
|
||||
filtersToQuery,
|
||||
LIBRARY_PAGE_SIZE,
|
||||
useModelSharingStore,
|
||||
} from "./model-sharing-store";
|
||||
|
||||
function resetStore() {
|
||||
useModelSharingStore.setState({
|
||||
items: [],
|
||||
filters: { ...DEFAULT_LIBRARY_FILTERS },
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
listError: null,
|
||||
profile: null,
|
||||
isProfileLoading: false,
|
||||
profileError: null,
|
||||
shares: [],
|
||||
isSharesLoading: false,
|
||||
_mockMode: true, // 測試一律走 mock,不打真實 API
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStore();
|
||||
});
|
||||
|
||||
describe("loadFirstPage", () => {
|
||||
it("載入首頁 → items = PAGE_SIZE、hasMore=true、cursor 非空", async () => {
|
||||
await useModelSharingStore.getState().loadFirstPage();
|
||||
const s = useModelSharingStore.getState();
|
||||
expect(s.items).toHaveLength(LIBRARY_PAGE_SIZE);
|
||||
expect(s.hasMore).toBe(true);
|
||||
expect(s.cursor).not.toBeNull();
|
||||
expect(s.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadMore — cursor 無限捲動", () => {
|
||||
it("續載 → append 下一頁且不與首頁重複", async () => {
|
||||
const store = useModelSharingStore.getState();
|
||||
await store.loadFirstPage();
|
||||
const firstIds = useModelSharingStore.getState().items.map((m) => m.id);
|
||||
|
||||
await useModelSharingStore.getState().loadMore();
|
||||
const all = useModelSharingStore.getState().items;
|
||||
|
||||
// 續載後總數 > 首頁
|
||||
expect(all.length).toBeGreaterThan(firstIds.length);
|
||||
// 無重複 id
|
||||
expect(new Set(all.map((m) => m.id)).size).toBe(all.length);
|
||||
});
|
||||
|
||||
it("一路 loadMore 到底 → hasMore=false、涵蓋全部 30 筆", async () => {
|
||||
await useModelSharingStore.getState().loadFirstPage();
|
||||
let guard = 0;
|
||||
while (useModelSharingStore.getState().hasMore) {
|
||||
await useModelSharingStore.getState().loadMore();
|
||||
if (++guard > 10) throw new Error("loadMore 未收斂");
|
||||
}
|
||||
const s = useModelSharingStore.getState();
|
||||
expect(s.hasMore).toBe(false);
|
||||
expect(s.items).toHaveLength(30); // mock fixtures 共 30 筆
|
||||
});
|
||||
|
||||
it("重入防護:無 cursor(未載入首頁)→ loadMore 不改變 items", async () => {
|
||||
await useModelSharingStore.getState().loadMore();
|
||||
expect(useModelSharingStore.getState().items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setFilters", () => {
|
||||
it("設 owned=mine → 重置分頁並只留我的模型", async () => {
|
||||
await useModelSharingStore.getState().loadFirstPage();
|
||||
useModelSharingStore.getState().setFilters({ owned: "mine" });
|
||||
// setFilters 內部呼叫 loadFirstPage(async);等 microtask
|
||||
await Promise.resolve();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const s = useModelSharingStore.getState();
|
||||
expect(s.filters.owned).toBe("mine");
|
||||
expect(s.items.every((m) => m.owner.isMe)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadProfile", () => {
|
||||
it("命中 mock id → 設 profile", async () => {
|
||||
await useModelSharingStore.getState().loadProfile("mock-model-01");
|
||||
const s = useModelSharingStore.getState();
|
||||
expect(s.profile?.id).toBe("mock-model-01");
|
||||
expect(s.profileError).toBeNull();
|
||||
});
|
||||
|
||||
it("不存在 id → profileError=not_found(模擬 404 防 enumeration)", async () => {
|
||||
await useModelSharingStore.getState().loadProfile("no-such-model");
|
||||
const s = useModelSharingStore.getState();
|
||||
expect(s.profile).toBeNull();
|
||||
expect(s.profileError).toBe("not_found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("公開設定樂觀更新", () => {
|
||||
it("updateVisibility → 更新 items 中對應項與 profile", async () => {
|
||||
await useModelSharingStore.getState().loadProfile("mock-model-01");
|
||||
const result = await useModelSharingStore
|
||||
.getState()
|
||||
.updateVisibility("mock-model-01", "public");
|
||||
expect(result.ok).toBe(true);
|
||||
expect(useModelSharingStore.getState().profile?.visibility).toBe("public");
|
||||
});
|
||||
|
||||
it("addShare → 加入 shares;removeShare → 移除", async () => {
|
||||
await useModelSharingStore.getState().loadShares("mock-model-01");
|
||||
const before = useModelSharingStore.getState().shares.length;
|
||||
|
||||
const add = await useModelSharingStore
|
||||
.getState()
|
||||
.addShare("mock-model-01", "new@corp.com");
|
||||
expect(add.ok).toBe(true);
|
||||
expect(useModelSharingStore.getState().shares).toHaveLength(before + 1);
|
||||
|
||||
const added = useModelSharingStore
|
||||
.getState()
|
||||
.shares.find((s) => s.email === "new@corp.com");
|
||||
const remove = await useModelSharingStore
|
||||
.getState()
|
||||
.removeShare("mock-model-01", added!.userId);
|
||||
expect(remove.ok).toBe(true);
|
||||
expect(useModelSharingStore.getState().shares).toHaveLength(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filtersToQuery", () => {
|
||||
it("all / 空值省略;mine → owned=true", () => {
|
||||
const q = filtersToQuery({
|
||||
...DEFAULT_LIBRARY_FILTERS,
|
||||
owned: "mine",
|
||||
q: " hello ",
|
||||
});
|
||||
expect(q.owned).toBe(true);
|
||||
expect(q.q).toBe("hello"); // trim
|
||||
expect(q.targetChip).toBeUndefined(); // all → 省略
|
||||
expect(q.visibility).toBeUndefined();
|
||||
expect(q.limit).toBe(LIBRARY_PAGE_SIZE);
|
||||
});
|
||||
|
||||
it("shared → owned=false;具體 filter 帶上", () => {
|
||||
const q = filtersToQuery({
|
||||
q: "",
|
||||
targetChip: "kl720",
|
||||
visibility: "public",
|
||||
owned: "shared",
|
||||
sort: "name",
|
||||
order: "asc",
|
||||
});
|
||||
expect(q.owned).toBe(false);
|
||||
expect(q.targetChip).toBe("kl720");
|
||||
expect(q.visibility).toBe("public");
|
||||
expect(q.sort).toBe("name");
|
||||
expect(q.order).toBe("asc");
|
||||
});
|
||||
|
||||
it("帶 cursor → query 含 cursor", () => {
|
||||
const q = filtersToQuery(DEFAULT_LIBRARY_FILTERS, "CURSOR123");
|
||||
expect(q.cursor).toBe("CURSOR123");
|
||||
});
|
||||
});
|
||||
366
visionA-frontend/src/stores/model-sharing-store.ts
Normal file
366
visionA-frontend/src/stores/model-sharing-store.ts
Normal file
@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Model Sharing Store — visionA Cloud(模型共享 L 級新功能)
|
||||
*
|
||||
* 管理三塊狀態:
|
||||
* 1. 共享模型庫列表(cursor 無限捲動分頁 + 搜尋 / filter / 排序)
|
||||
* 2. 模型 profile(公開版詳情,依身份雙態)
|
||||
* 3. 公開設定 Dialog(visibility + shares 授權清單)
|
||||
*
|
||||
* 對齊契約 `api-model-sharing.md`。API 層在 `lib/api/model-sharing.ts`。
|
||||
*
|
||||
* ## 平行開發 mock 模式
|
||||
* `NEXT_PUBLIC_USE_MODEL_SHARING_MOCK=1`(或測試以 `_setMockMode(true)`)時,
|
||||
* 走 `model-sharing.mock.ts` 的 fixtures,不打真實 API。契約 response 形狀一致,
|
||||
* 後端就緒後移除 flag 即可切換,UI / normalize 邏輯不變。
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
import {
|
||||
addShare as apiAddShare,
|
||||
fetchLibrary as apiFetchLibrary,
|
||||
fetchProfile as apiFetchProfile,
|
||||
fetchShares as apiFetchShares,
|
||||
removeShare as apiRemoveShare,
|
||||
updateVisibility as apiUpdateVisibility,
|
||||
normalizeLibraryPage,
|
||||
normalizeProfile,
|
||||
ModelSharingError,
|
||||
type LibraryModel,
|
||||
type LibraryQuery,
|
||||
type LibrarySort,
|
||||
type ModelProfile,
|
||||
type ModelShare,
|
||||
type ModelVisibility,
|
||||
type SortOrder,
|
||||
} from "@/lib/api/model-sharing";
|
||||
import {
|
||||
mockLibraryPage,
|
||||
mockProfile,
|
||||
MOCK_SHARES,
|
||||
} from "@/lib/api/model-sharing.mock";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Mock 模式判定 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function envMockMode(): boolean {
|
||||
return (
|
||||
typeof process !== "undefined" &&
|
||||
process.env?.NEXT_PUBLIC_USE_MODEL_SHARING_MOCK === "1"
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 列表篩選 / 排序狀態 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** 共享庫的可見性 filter(UI 用;all = 不過濾)。 */
|
||||
export type LibraryVisibilityFilter = "all" | "public" | "tenant";
|
||||
|
||||
/** 擁有關係 filter(UI 用;all = 全部可見)。 */
|
||||
export type LibraryOwnedFilter = "all" | "mine" | "shared";
|
||||
|
||||
export interface LibraryFilters {
|
||||
q: string;
|
||||
targetChip: "all" | "kl520" | "kl720" | "kl630" | "kl730";
|
||||
visibility: LibraryVisibilityFilter;
|
||||
owned: LibraryOwnedFilter;
|
||||
sort: LibrarySort;
|
||||
order: SortOrder;
|
||||
}
|
||||
|
||||
export const DEFAULT_LIBRARY_FILTERS: LibraryFilters = {
|
||||
q: "",
|
||||
targetChip: "all",
|
||||
visibility: "all",
|
||||
owned: "all",
|
||||
sort: "created_at",
|
||||
order: "desc",
|
||||
};
|
||||
|
||||
/** 每頁筆數(對齊設計規格 §4.6:desktop 3 欄 × 8 列)。 */
|
||||
export const LIBRARY_PAGE_SIZE = 24;
|
||||
|
||||
/** 把 UI filters 轉成 API query(省略 all / 空值)。 */
|
||||
export function filtersToQuery(
|
||||
filters: LibraryFilters,
|
||||
cursor?: string,
|
||||
): LibraryQuery {
|
||||
const query: LibraryQuery = {
|
||||
limit: LIBRARY_PAGE_SIZE,
|
||||
sort: filters.sort,
|
||||
order: filters.order,
|
||||
};
|
||||
if (cursor) query.cursor = cursor;
|
||||
if (filters.q.trim()) query.q = filters.q.trim();
|
||||
if (filters.targetChip !== "all") query.targetChip = filters.targetChip;
|
||||
if (filters.visibility !== "all") query.visibility = filters.visibility;
|
||||
if (filters.owned === "mine") query.owned = true;
|
||||
else if (filters.owned === "shared") query.owned = false;
|
||||
return query;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Store 型別 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** 分享對象操作的結果(帶 i18n code 給 UI 顯示)。 */
|
||||
export type ShareOpResult =
|
||||
| { ok: true }
|
||||
| { ok: false; code: string; message: string };
|
||||
|
||||
interface ModelSharingState {
|
||||
/* ── 共享庫列表 ── */
|
||||
items: LibraryModel[];
|
||||
filters: LibraryFilters;
|
||||
cursor: string | null;
|
||||
hasMore: boolean;
|
||||
/** 首屏 / filter 變更後的整體載入。 */
|
||||
isLoading: boolean;
|
||||
/** 「載入更多」(cursor 續載)中。 */
|
||||
isLoadingMore: boolean;
|
||||
/** 列表載入錯誤(i18n code);null = 無錯誤。 */
|
||||
listError: string | null;
|
||||
|
||||
/* ── profile ── */
|
||||
profile: ModelProfile | null;
|
||||
isProfileLoading: boolean;
|
||||
/** profile 錯誤 code(如 not_found → 無權限 / 找不到)。 */
|
||||
profileError: string | null;
|
||||
|
||||
/* ── 公開設定(shares) ── */
|
||||
shares: ModelShare[];
|
||||
isSharesLoading: boolean;
|
||||
|
||||
/* ── actions ── */
|
||||
/** 設定 filters(會重置分頁並重新載入首頁)。 */
|
||||
setFilters: (patch: Partial<LibraryFilters>) => void;
|
||||
/** 載入首頁(reset 已載入項 + cursor)。 */
|
||||
loadFirstPage: () => Promise<void>;
|
||||
/** cursor 續載下一頁(append)。 */
|
||||
loadMore: () => Promise<void>;
|
||||
|
||||
/** 載入 profile。 */
|
||||
loadProfile: (id: string) => Promise<void>;
|
||||
clearProfile: () => void;
|
||||
|
||||
/** 載入授權清單。 */
|
||||
loadShares: (id: string) => Promise<void>;
|
||||
/** 更新可見性。 */
|
||||
updateVisibility: (
|
||||
id: string,
|
||||
visibility: ModelVisibility,
|
||||
) => Promise<ShareOpResult>;
|
||||
/** 新增授權對象。 */
|
||||
addShare: (id: string, email: string) => Promise<ShareOpResult>;
|
||||
/** 移除授權對象。 */
|
||||
removeShare: (id: string, userId: string) => Promise<ShareOpResult>;
|
||||
|
||||
/* ── 測試 / mock ── */
|
||||
_mockMode: boolean;
|
||||
_setMockMode: (on: boolean) => void;
|
||||
_setItems: (items: LibraryModel[]) => void;
|
||||
_setProfile: (p: ModelProfile | null) => void;
|
||||
_setShares: (s: ModelShare[]) => void;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Mock 分頁 / profile / shares(走 fixtures) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function mockFetchLibrary(query: LibraryQuery) {
|
||||
const raw = mockLibraryPage({
|
||||
cursor: query.cursor,
|
||||
limit: query.limit,
|
||||
q: query.q,
|
||||
targetChip: query.targetChip,
|
||||
source: query.source,
|
||||
visibility: query.visibility,
|
||||
owned: query.owned,
|
||||
sort: query.sort,
|
||||
order: query.order,
|
||||
});
|
||||
return normalizeLibraryPage(raw);
|
||||
}
|
||||
|
||||
function mockFetchProfile(id: string): ModelProfile {
|
||||
const raw = mockProfile(id);
|
||||
if (!raw) {
|
||||
throw new ModelSharingError(404, "not_found", "model not found");
|
||||
}
|
||||
return normalizeProfile(raw);
|
||||
}
|
||||
|
||||
function mockFetchShares(id: string): ModelShare[] {
|
||||
const list = MOCK_SHARES[id] ?? [];
|
||||
return list.map((s) => ({
|
||||
userId: s.user_id,
|
||||
email: s.email,
|
||||
role: s.role === "editor" ? "editor" : "viewer",
|
||||
createdAt: s.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Store */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const useModelSharingStore = create<ModelSharingState>()((set, get) => ({
|
||||
items: [],
|
||||
filters: { ...DEFAULT_LIBRARY_FILTERS },
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
listError: null,
|
||||
|
||||
profile: null,
|
||||
isProfileLoading: false,
|
||||
profileError: null,
|
||||
|
||||
shares: [],
|
||||
isSharesLoading: false,
|
||||
|
||||
_mockMode: envMockMode(),
|
||||
|
||||
setFilters: (patch) => {
|
||||
set((state) => ({ filters: { ...state.filters, ...patch } }));
|
||||
void get().loadFirstPage();
|
||||
},
|
||||
|
||||
loadFirstPage: async () => {
|
||||
const { filters, _mockMode } = get();
|
||||
set({ isLoading: true, listError: null, items: [], cursor: null, hasMore: false });
|
||||
try {
|
||||
const query = filtersToQuery(filters);
|
||||
const page = _mockMode ? mockFetchLibrary(query) : await apiFetchLibrary(query);
|
||||
set({
|
||||
items: page.items,
|
||||
cursor: page.nextCursor,
|
||||
hasMore: page.hasMore,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (err) {
|
||||
const code = err instanceof ModelSharingError ? err.code : "unknown";
|
||||
set({ isLoading: false, listError: code });
|
||||
}
|
||||
},
|
||||
|
||||
loadMore: async () => {
|
||||
const { filters, cursor, hasMore, isLoadingMore, isLoading, _mockMode } = get();
|
||||
// 防呆:無下一頁 / 正在載入時不重複觸發(無限捲動 observer 可能連續觸發)。
|
||||
if (!hasMore || !cursor || isLoadingMore || isLoading) return;
|
||||
set({ isLoadingMore: true, listError: null });
|
||||
try {
|
||||
const query = filtersToQuery(filters, cursor);
|
||||
const page = _mockMode ? mockFetchLibrary(query) : await apiFetchLibrary(query);
|
||||
set((state) => ({
|
||||
items: [...state.items, ...page.items],
|
||||
cursor: page.nextCursor,
|
||||
hasMore: page.hasMore,
|
||||
isLoadingMore: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
const code = err instanceof ModelSharingError ? err.code : "unknown";
|
||||
set({ isLoadingMore: false, listError: code });
|
||||
}
|
||||
},
|
||||
|
||||
loadProfile: async (id) => {
|
||||
const { _mockMode } = get();
|
||||
set({ isProfileLoading: true, profileError: null, profile: null });
|
||||
try {
|
||||
const profile = _mockMode ? mockFetchProfile(id) : await apiFetchProfile(id);
|
||||
set({ profile, isProfileLoading: false });
|
||||
} catch (err) {
|
||||
const code = err instanceof ModelSharingError ? err.code : "unknown";
|
||||
set({ isProfileLoading: false, profileError: code });
|
||||
}
|
||||
},
|
||||
|
||||
clearProfile: () => set({ profile: null, profileError: null }),
|
||||
|
||||
loadShares: async (id) => {
|
||||
const { _mockMode } = get();
|
||||
set({ isSharesLoading: true });
|
||||
try {
|
||||
const shares = _mockMode ? mockFetchShares(id) : await apiFetchShares(id);
|
||||
set({ shares, isSharesLoading: false });
|
||||
} catch {
|
||||
// 載入授權清單失敗時清空 + 停止 loading;Dialog UI 顯示空清單,操作仍可重試。
|
||||
set({ shares: [], isSharesLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateVisibility: async (id, visibility) => {
|
||||
const { _mockMode } = get();
|
||||
try {
|
||||
if (!_mockMode) {
|
||||
await apiUpdateVisibility(id, visibility);
|
||||
}
|
||||
// 樂觀更新 profile 與列表中對應項的 visibility。
|
||||
set((state) => ({
|
||||
profile:
|
||||
state.profile?.id === id
|
||||
? { ...state.profile, visibility }
|
||||
: state.profile,
|
||||
items: state.items.map((m) =>
|
||||
m.id === id ? { ...m, visibility } : m,
|
||||
),
|
||||
}));
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const code = err instanceof ModelSharingError ? err.code : "unknown";
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, code, message };
|
||||
}
|
||||
},
|
||||
|
||||
addShare: async (id, email) => {
|
||||
const { _mockMode } = get();
|
||||
try {
|
||||
let newShare: ModelShare;
|
||||
if (_mockMode) {
|
||||
newShare = {
|
||||
userId: `u-${email}`,
|
||||
email,
|
||||
role: "viewer",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
} else {
|
||||
newShare = await apiAddShare(id, email);
|
||||
}
|
||||
set((state) => ({ shares: [...state.shares, newShare] }));
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const code = err instanceof ModelSharingError ? err.code : "unknown";
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, code, message };
|
||||
}
|
||||
},
|
||||
|
||||
removeShare: async (id, userId) => {
|
||||
const { _mockMode } = get();
|
||||
try {
|
||||
if (!_mockMode) {
|
||||
await apiRemoveShare(id, userId);
|
||||
}
|
||||
set((state) => ({
|
||||
shares: state.shares.filter((s) => s.userId !== userId),
|
||||
}));
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const code = err instanceof ModelSharingError ? err.code : "unknown";
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, code, message };
|
||||
}
|
||||
},
|
||||
|
||||
_setMockMode: (on) => set({ _mockMode: on }),
|
||||
_setItems: (items) => set({ items }),
|
||||
_setProfile: (profile) => set({ profile }),
|
||||
_setShares: (shares) => set({ shares }),
|
||||
}));
|
||||
@ -36,6 +36,8 @@ export type KnownErrorCode =
|
||||
| "FORBIDDEN"
|
||||
| "NOT_FOUND"
|
||||
| "VALIDATION_FAILED"
|
||||
| "REPRESENTATIVE_DEVICE" // 409:representative device 不可 register/unregister(api-device-mgmt.md §3,backend 實際回碼)
|
||||
| "ALREADY_REGISTERED" // 409:register 時裝置已註冊(api-device-mgmt.md §3,本功能新增)
|
||||
| "TUNNEL_DISCONNECTED"
|
||||
| "TUNNEL_ERROR"
|
||||
| "NOT_IMPLEMENTED"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user