diff --git a/.gitignore b/.gitignore index 0243717..020375e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ .claude/settings.local.json CLAUDE.md +.omc # ip2region data files (download separately) /data/*.xdb diff --git a/.golangci.yml b/.golangci.yml index 259f1cf..54c3826 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -30,6 +30,10 @@ linters: misspell: locale: US + exclusions: + paths: + - docs + formatters: enable: - gofmt diff --git a/cmd/app/main.go b/cmd/app/main.go index 02aa8b2..f9e9cd8 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -30,10 +30,13 @@ import ( "github.com/scriptscat/scriptlist/internal/repository/report_repo" "github.com/scriptscat/scriptlist/internal/repository/resource_repo" "github.com/scriptscat/scriptlist/internal/repository/script_repo" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" "github.com/scriptscat/scriptlist/internal/repository/statistics_repo" "github.com/scriptscat/scriptlist/internal/repository/user_repo" + "github.com/scriptscat/scriptlist/internal/service/similarity_svc" "github.com/scriptscat/scriptlist/internal/task/consumer" "github.com/scriptscat/scriptlist/internal/task/crontab" + "github.com/scriptscat/scriptlist/internal/task/crontab/handler" "github.com/scriptscat/scriptlist/migrations" ) @@ -103,6 +106,24 @@ func main() { announcement_repo.RegisterAnnouncement(announcement_repo.NewAnnouncement()) + // similarity / integrity + similarity_repo.RegisterFingerprint(similarity_repo.NewFingerprintRepo()) + similarity_repo.RegisterSimilarPair(similarity_repo.NewSimilarPairRepo()) + similarity_repo.RegisterSuspectSummary(similarity_repo.NewSuspectSummaryRepo()) + similarity_repo.RegisterSimilarityWhitelist(similarity_repo.NewSimilarityWhitelistRepo()) + similarity_repo.RegisterIntegrityWhitelist(similarity_repo.NewIntegrityWhitelistRepo()) + similarity_repo.RegisterIntegrityReview(similarity_repo.NewIntegrityReviewRepo()) + similarity_repo.RegisterFingerprintES(similarity_repo.NewFingerprintESRepo()) + similarity_repo.RegisterPatrolQuery(similarity_repo.NewPatrolQueryRepo()) + similarity_svc.RegisterIntegrity(similarity_svc.NewIntegritySvc()) + similarity_svc.RegisterScan(similarity_svc.NewScanSvc()) + similarity_svc.RegisterAccess(similarity_svc.NewAccessSvc()) + similarity_svc.RegisterAdmin(similarity_svc.NewAdminSvc()) + // Wire the crontab handlers' long-running methods into admin_svc so + // the admin endpoints can invoke them without an import cycle. + similarity_svc.RegisterBackfillRunner(handler.NewSimilarityPatrolHandler().RunBackfill) + similarity_svc.RegisterStopFpRefresher(handler.NewSimilarityStopFpHandler().Refresh) + err = cago.New(ctx, cfg). Registry(component.Core()). Registry(db.Database()). @@ -118,6 +139,12 @@ func main() { return nil })). Registry(cago.FuncComponent(appconfigs.Validate)). + Registry(cago.FuncComponent(func(ctx context.Context, cfg *configs.Config) error { + if !appconfigs.Similarity().ScanEnabled { + return nil + } + return similarity_repo.EnsureFingerprintIndex(ctx) + })). Registry(cago.FuncComponent(func(ctx context.Context, cfg *configs.Config) error { v4Path := cfg.String(ctx, "ip2region.v4_xdb_path") v6Path := cfg.String(ctx, "ip2region.v6_xdb_path") diff --git a/configs/config.go b/configs/config.go index 4023476..cb4e527 100644 --- a/configs/config.go +++ b/configs/config.go @@ -101,11 +101,148 @@ func QQMigrate() *QQMigrateConfig { return cfg } +// SimilarityConfig 相似度检测系统配置(YAML + DB 覆盖) +type SimilarityConfig struct { + ScanEnabled bool `yaml:"scan_enabled"` + JaccardThreshold float64 `yaml:"jaccard_threshold"` + CoverageThreshold float64 `yaml:"coverage_threshold"` + KGramSize int `yaml:"kgram_size"` + WinnowingWindow int `yaml:"winnowing_window"` + MinFingerprints int `yaml:"min_fingerprints"` + MaxCodeSize int `yaml:"max_code_size"` + StopFpDfCutoff int `yaml:"stop_fp_df_cutoff"` + StopFpRefreshSec int `yaml:"stop_fp_refresh_sec"` + BackfillBatchSize int `yaml:"backfill_batch_size"` + BackfillSleepMs int `yaml:"backfill_sleep_ms"` + IntegrityEnabled bool `yaml:"integrity_enabled"` + IntegrityWarnThreshold float64 `yaml:"integrity_warn_threshold"` + IntegrityBlockThreshold float64 `yaml:"integrity_block_threshold"` + IntegrityAsyncAutoArchive *bool `yaml:"integrity_async_auto_archive"` +} + +// Similarity 返回相似度系统配置。读取顺序: +// 1. YAML 文件(configs/config.yaml)— 进程启动时的基线 +// 2. spec §6.1 默认值 — 填补 YAML 未声明字段 +// 3. pre_system_config 表的 `similarity.*` 行 — 管理员后台动态覆盖 +// +// DB override 允许管理员在不重启服务的情况下调整阈值 / 开关,符合 spec §1.1 +// "可配置阈值:管理员可动态调整相似度阈值、覆盖率阈值、完整性检查阈值"。 +// DB 行写入失败或解析失败时静默回退到 YAML 值。 +func Similarity() *SimilarityConfig { + // 预填 spec §6.1 的 bool 默认值——YAML Scan 只会覆盖被显式声明的字段, + // 所以即使 YAML 完全省略 similarity 段,bool 仍是 true(spec 默认)。 + cfg := &SimilarityConfig{ + ScanEnabled: true, + IntegrityEnabled: true, + } + if d := configs.Default(); d != nil { + _ = d.Scan(context.Background(), "similarity", cfg) + } + // Apply defaults to any zero-valued field (zero is sentinel for "unset" here). + if cfg.JaccardThreshold == 0 { + cfg.JaccardThreshold = 0.30 + } + if cfg.CoverageThreshold == 0 { + cfg.CoverageThreshold = 0.50 + } + if cfg.KGramSize == 0 { + cfg.KGramSize = 5 + } + if cfg.WinnowingWindow == 0 { + cfg.WinnowingWindow = 10 + } + if cfg.MinFingerprints == 0 { + cfg.MinFingerprints = 20 + } + // MaxCodeSize: 0 means unlimited (subject to the API-level 10MB cap on + // Code). scan.go gates on `MaxCodeSize > 0` so zero disables the guard. + if cfg.StopFpDfCutoff == 0 { + cfg.StopFpDfCutoff = 50 + } + if cfg.StopFpRefreshSec == 0 { + cfg.StopFpRefreshSec = 3600 + } + if cfg.BackfillBatchSize == 0 { + cfg.BackfillBatchSize = 50 + } + if cfg.BackfillSleepMs == 0 { + cfg.BackfillSleepMs = 200 + } + if cfg.IntegrityWarnThreshold == 0 { + cfg.IntegrityWarnThreshold = 0.5 + } + if cfg.IntegrityBlockThreshold == 0 { + cfg.IntegrityBlockThreshold = 0.8 + } + if cfg.IntegrityAsyncAutoArchive == nil { + t := true + cfg.IntegrityAsyncAutoArchive = &t + } + // DB overrides (admin-tunable at runtime per spec §1.1 / §6.1). + if dbProvider != nil { + ctx := context.Background() + if v, ok := dbProvider.GetBool(ctx, "similarity.scan_enabled"); ok { + cfg.ScanEnabled = v + } + if v, ok := dbProvider.GetFloat(ctx, "similarity.jaccard_threshold"); ok { + cfg.JaccardThreshold = v + } + if v, ok := dbProvider.GetFloat(ctx, "similarity.coverage_threshold"); ok { + cfg.CoverageThreshold = v + } + if v, ok := dbProvider.GetInt(ctx, "similarity.kgram_size"); ok { + cfg.KGramSize = v + } + if v, ok := dbProvider.GetInt(ctx, "similarity.winnowing_window"); ok { + cfg.WinnowingWindow = v + } + if v, ok := dbProvider.GetInt(ctx, "similarity.min_fingerprints"); ok { + cfg.MinFingerprints = v + } + if v, ok := dbProvider.GetInt(ctx, "similarity.max_code_size"); ok { + cfg.MaxCodeSize = v + } + if v, ok := dbProvider.GetInt(ctx, "similarity.stop_fp_df_cutoff"); ok { + cfg.StopFpDfCutoff = v + } + if v, ok := dbProvider.GetInt(ctx, "similarity.stop_fp_refresh_sec"); ok { + cfg.StopFpRefreshSec = v + } + if v, ok := dbProvider.GetInt(ctx, "similarity.backfill_batch_size"); ok { + cfg.BackfillBatchSize = v + } + if v, ok := dbProvider.GetInt(ctx, "similarity.backfill_sleep_ms"); ok { + cfg.BackfillSleepMs = v + } + if v, ok := dbProvider.GetBool(ctx, "similarity.integrity_enabled"); ok { + cfg.IntegrityEnabled = v + } + if v, ok := dbProvider.GetFloat(ctx, "similarity.integrity_warn_threshold"); ok { + cfg.IntegrityWarnThreshold = v + } + if v, ok := dbProvider.GetFloat(ctx, "similarity.integrity_block_threshold"); ok { + cfg.IntegrityBlockThreshold = v + } + if v, ok := dbProvider.GetBool(ctx, "similarity.integrity_async_auto_archive"); ok { + cfg.IntegrityAsyncAutoArchive = &v + } + } + return cfg +} + // Validate 在服务启动时检查必要配置项(符合 CaGo FuncComponent 签名) // 其余配置(turnstile、ai)可通过管理后台动态配置,不在启动时强制校验 func Validate(ctx context.Context, cfg *configs.Config) error { if cfg.String(ctx, "website.url") == "" { return fmt.Errorf("missing required config key: website.url") } + // similarity.scan_enabled=true 需要 elasticsearch 地址(cago 读取 elasticsearch.address 列表) + if Similarity().ScanEnabled { + var esAddress []string + _ = cfg.Scan(ctx, "elasticsearch.address", &esAddress) + if len(esAddress) == 0 { + return fmt.Errorf("similarity.scan_enabled=true requires elasticsearch.address to be set") + } + } return nil } diff --git a/configs/config.yaml.example b/configs/config.yaml.example index 4738e82..bbb0ebc 100644 --- a/configs/config.yaml.example +++ b/configs/config.yaml.example @@ -50,5 +50,21 @@ qq_migrate: ip2region: v4_xdb_path: "data/ip2region_v4.xdb" v6_xdb_path: "data/ip2region_v6.xdb" +similarity: + scan_enabled: true + jaccard_threshold: 0.30 + coverage_threshold: 0.50 + kgram_size: 5 + winnowing_window: 10 + min_fingerprints: 20 + max_code_size: 0 # 0 = unlimited (API already caps Code at 10MB) + stop_fp_df_cutoff: 50 + stop_fp_refresh_sec: 3600 + backfill_batch_size: 50 + backfill_sleep_ms: 200 + integrity_enabled: true + integrity_warn_threshold: 0.5 + integrity_block_threshold: 0.8 + integrity_async_auto_archive: true source: file version: 2.0.0 diff --git a/configs/db_provider.go b/configs/db_provider.go index c3c0353..630023d 100644 --- a/configs/db_provider.go +++ b/configs/db_provider.go @@ -2,6 +2,7 @@ package configs import ( "context" + "strconv" "github.com/scriptscat/scriptlist/internal/repository/system_config_repo" ) @@ -25,6 +26,51 @@ func (p *DBConfigProvider) GetString(ctx context.Context, key string) (string, b return cfg.ConfigValue, true } +// GetBool returns the DB-stored value for key as a bool. Recognized truthy +// literals: "true", "1", "yes", "on" (case-insensitive). Missing rows and +// parse failures return (false, false) so callers can fall back to YAML. +func (p *DBConfigProvider) GetBool(ctx context.Context, key string) (bool, bool) { + raw, ok := p.GetString(ctx, key) + if !ok { + return false, false + } + switch raw { + case "true", "True", "TRUE", "1", "yes", "Yes", "YES", "on", "On", "ON": + return true, true + case "false", "False", "FALSE", "0", "no", "No", "NO", "off", "Off", "OFF": + return false, true + } + return false, false +} + +// GetFloat returns the DB-stored value for key as a float64. Missing rows +// and parse failures return (0, false). +func (p *DBConfigProvider) GetFloat(ctx context.Context, key string) (float64, bool) { + raw, ok := p.GetString(ctx, key) + if !ok { + return 0, false + } + v, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0, false + } + return v, true +} + +// GetInt returns the DB-stored value for key as an int. Missing rows and +// parse failures return (0, false). +func (p *DBConfigProvider) GetInt(ctx context.Context, key string) (int, bool) { + raw, ok := p.GetString(ctx, key) + if !ok { + return 0, false + } + v, err := strconv.Atoi(raw) + if err != nil { + return 0, false + } + return v, true +} + func (p *DBConfigProvider) GetByPrefix(ctx context.Context, prefix string) (map[string]string, error) { repo := system_config_repo.SystemConfig() if repo == nil { diff --git a/configs/db_provider_test.go b/configs/db_provider_test.go new file mode 100644 index 0000000..fa9bfc5 --- /dev/null +++ b/configs/db_provider_test.go @@ -0,0 +1,142 @@ +package configs + +import ( + "context" + "sync" + "testing" + + "github.com/scriptscat/scriptlist/internal/model/entity/system_config_entity" + "github.com/scriptscat/scriptlist/internal/repository/system_config_repo" + mock_system_config_repo "github.com/scriptscat/scriptlist/internal/repository/system_config_repo/mock" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +// configsMu serializes access to the package-level system_config service +// locator so these tests don't race on RegisterSystemConfig. +var configsMu sync.Mutex + +func setupDBConfigProviderMock(t *testing.T) *mock_system_config_repo.MockSystemConfigRepo { + configsMu.Lock() + t.Cleanup(configsMu.Unlock) + ctrl := gomock.NewController(t) + m := mock_system_config_repo.NewMockSystemConfigRepo(ctrl) + system_config_repo.RegisterSystemConfig(m) + return m +} + +func row(key, value string) *system_config_entity.SystemConfig { + return &system_config_entity.SystemConfig{ConfigKey: key, ConfigValue: value} +} + +func TestDBConfigProvider_GetBool(t *testing.T) { + m := setupDBConfigProviderMock(t) + p := NewDBConfigProvider() + ctx := context.Background() + + cases := []struct { + value string + wantBool bool + wantOk bool + }{ + {"true", true, true}, + {"TRUE", true, true}, + {"1", true, true}, + {"yes", true, true}, + {"on", true, true}, + {"false", false, true}, + {"0", false, true}, + {"no", false, true}, + {"off", false, true}, + {"garbage", false, false}, + } + for _, c := range cases { + m.EXPECT().FindByKey(gomock.Any(), "k").Return(row("k", c.value), nil) + v, ok := p.GetBool(ctx, "k") + assert.Equal(t, c.wantOk, ok, "value=%q", c.value) + if ok { + assert.Equal(t, c.wantBool, v, "value=%q", c.value) + } + } + + // Missing row → (false, false). + m.EXPECT().FindByKey(gomock.Any(), "k").Return(nil, nil) + _, ok := p.GetBool(ctx, "k") + assert.False(t, ok) +} + +func TestDBConfigProvider_GetFloat(t *testing.T) { + m := setupDBConfigProviderMock(t) + p := NewDBConfigProvider() + ctx := context.Background() + + m.EXPECT().FindByKey(gomock.Any(), "k").Return(row("k", "0.5"), nil) + v, ok := p.GetFloat(ctx, "k") + assert.True(t, ok) + assert.Equal(t, 0.5, v) + + m.EXPECT().FindByKey(gomock.Any(), "k").Return(row("k", "not-a-number"), nil) + _, ok = p.GetFloat(ctx, "k") + assert.False(t, ok) + + m.EXPECT().FindByKey(gomock.Any(), "k").Return(nil, nil) + _, ok = p.GetFloat(ctx, "k") + assert.False(t, ok) +} + +func TestDBConfigProvider_GetInt(t *testing.T) { + m := setupDBConfigProviderMock(t) + p := NewDBConfigProvider() + ctx := context.Background() + + m.EXPECT().FindByKey(gomock.Any(), "k").Return(row("k", "42"), nil) + v, ok := p.GetInt(ctx, "k") + assert.True(t, ok) + assert.Equal(t, 42, v) + + m.EXPECT().FindByKey(gomock.Any(), "k").Return(row("k", "4.5"), nil) + _, ok = p.GetInt(ctx, "k") + assert.False(t, ok) + + m.EXPECT().FindByKey(gomock.Any(), "k").Return(nil, nil) + _, ok = p.GetInt(ctx, "k") + assert.False(t, ok) +} + +// TestSimilarity_DBOverridesYAML verifies the spec §1.1 / §6.1 contract that +// admin-tuned values in pre_system_config win over the YAML baseline. +func TestSimilarity_DBOverridesYAML(t *testing.T) { + m := setupDBConfigProviderMock(t) + origProvider := dbProvider + dbProvider = NewDBConfigProvider() + t.Cleanup(func() { dbProvider = origProvider }) + + // The call order follows the sequence in Similarity(); return overrides + // for a few interesting keys and pretend the rest are missing. + overrides := map[string]string{ + "similarity.scan_enabled": "false", + "similarity.jaccard_threshold": "0.42", + "similarity.coverage_threshold": "0.67", + "similarity.kgram_size": "7", + "similarity.integrity_warn_threshold": "0.55", + "similarity.integrity_block_threshold": "0.85", + } + m.EXPECT().FindByKey(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, key string) (*system_config_entity.SystemConfig, error) { + if v, ok := overrides[key]; ok { + return row(key, v), nil + } + return nil, nil + }).AnyTimes() + + cfg := Similarity() + assert.False(t, cfg.ScanEnabled, "DB must override scan_enabled") + assert.InDelta(t, 0.42, cfg.JaccardThreshold, 1e-9) + assert.InDelta(t, 0.67, cfg.CoverageThreshold, 1e-9) + assert.Equal(t, 7, cfg.KGramSize) + assert.InDelta(t, 0.55, cfg.IntegrityWarnThreshold, 1e-9) + assert.InDelta(t, 0.85, cfg.IntegrityBlockThreshold, 1e-9) + // Keys not in the overrides map fall back to YAML / spec defaults. + assert.Equal(t, 10, cfg.WinnowingWindow) + assert.Equal(t, 20, cfg.MinFingerprints) +} diff --git a/docs/docs.go b/docs/docs.go index 50bb13c..81f4355 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1,6 +1,6 @@ // Package docs GENERATED BY THE COMMAND ABOVE; DO NOT EDIT // This file was generated by swaggo/swag at -// 2026-03-20 21:54:46.767695 +0800 CST m=+0.648578584 +// 2026-04-13 22:49:10.811934 +0800 CST m=+0.633402543 package docs import ( @@ -1135,9 +1135,8 @@ var doc = `{ } } }, - "/admin/system-configs": { + "/admin/similarity/integrity/reviews": { "get": { - "description": "获取系统配置", "consumes": [ "application/json" ], @@ -1145,13 +1144,13 @@ var doc = `{ "application/json" ], "tags": [ - "admin" + "similarity" ], - "summary": "获取系统配置", "parameters": [ { - "type": "string", - "name": "prefix", + "type": "integer", + "description": "0=pending 1=ok 2=violated", + "name": "status", "in": "query" } ], @@ -1164,7 +1163,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/admin.GetSystemConfigsResponse" + "$ref": "#/definitions/similarity.ListIntegrityReviewsResponse" }, "msg": { "type": "string" @@ -1179,9 +1178,10 @@ var doc = `{ } } } - }, - "put": { - "description": "更新系统配置", + } + }, + "/admin/similarity/integrity/reviews/{id}": { + "get": { "consumes": [ "application/json" ], @@ -1189,16 +1189,14 @@ var doc = `{ "application/json" ], "tags": [ - "admin" + "similarity" ], - "summary": "更新系统配置", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/admin.UpdateSystemConfigsRequest" - } + "type": "integer", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -1210,7 +1208,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/admin.UpdateSystemConfigsResponse" + "$ref": "#/definitions/similarity.GetIntegrityReviewResponse" }, "msg": { "type": "string" @@ -1227,9 +1225,8 @@ var doc = `{ } } }, - "/admin/users": { - "get": { - "description": "管理员获取用户列表", + "/admin/similarity/integrity/reviews/{id}/resolve": { + "post": { "consumes": [ "application/json" ], @@ -1237,14 +1234,21 @@ var doc = `{ "application/json" ], "tags": [ - "admin" + "similarity" ], - "summary": "管理员获取用户列表", "parameters": [ { - "type": "string", - "name": "keyword", - "in": "query" + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/similarity.ResolveIntegrityReviewRequest" + } } ], "responses": { @@ -1256,7 +1260,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/admin.ListUsersResponse" + "$ref": "#/definitions/similarity.ResolveIntegrityReviewResponse" }, "msg": { "type": "string" @@ -1273,9 +1277,8 @@ var doc = `{ } } }, - "/admin/users/{id}/admin-level": { - "put": { - "description": "更新用户管理员等级", + "/admin/similarity/integrity/whitelist": { + "get": { "consumes": [ "application/json" ], @@ -1283,23 +1286,7 @@ var doc = `{ "application/json" ], "tags": [ - "admin" - ], - "summary": "更新用户管理员等级", - "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/admin.UpdateUserAdminLevelRequest" - } - } + "similarity" ], "responses": { "200": { @@ -1310,7 +1297,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/admin.UpdateUserAdminLevelResponse" + "$ref": "#/definitions/similarity.ListIntegrityWhitelistResponse" }, "msg": { "type": "string" @@ -1325,11 +1312,8 @@ var doc = `{ } } } - } - }, - "/admin/users/{id}/status": { - "put": { - "description": "更新用户状态(封禁/解封)", + }, + "post": { "consumes": [ "application/json" ], @@ -1337,21 +1321,14 @@ var doc = `{ "application/json" ], "tags": [ - "admin" + "similarity" ], - "summary": "更新用户状态(封禁/解封)", "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/admin.UpdateUserStatusRequest" + "$ref": "#/definitions/similarity.AddIntegrityWhitelistRequest" } } ], @@ -1364,7 +1341,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/admin.UpdateUserStatusResponse" + "$ref": "#/definitions/similarity.AddIntegrityWhitelistResponse" }, "msg": { "type": "string" @@ -1381,9 +1358,8 @@ var doc = `{ } } }, - "/announcements": { - "get": { - "description": "公告列表", + "/admin/similarity/integrity/whitelist/{script_id}": { + "delete": { "consumes": [ "application/json" ], @@ -1391,14 +1367,21 @@ var doc = `{ "application/json" ], "tags": [ - "announcement" + "similarity" ], - "summary": "公告列表", "parameters": [ { - "type": "string", - "name": "locale", - "in": "query" + "type": "integer", + "name": "script_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/similarity.RemoveIntegrityWhitelistRequest" + } } ], "responses": { @@ -1410,7 +1393,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/announcement.ListResponse" + "$ref": "#/definitions/similarity.RemoveIntegrityWhitelistResponse" }, "msg": { "type": "string" @@ -1427,9 +1410,8 @@ var doc = `{ } } }, - "/announcements/latest": { + "/admin/similarity/pairs": { "get": { - "description": "最新重要公告", "consumes": [ "application/json" ], @@ -1437,13 +1419,23 @@ var doc = `{ "application/json" ], "tags": [ - "announcement" + "similarity" ], - "summary": "最新重要公告", "parameters": [ { - "type": "string", - "name": "locale", + "type": "integer", + "description": "nil = any, 0/1/2 = filter", + "name": "status", + "in": "query" + }, + { + "type": "number", + "name": "min_jaccard", + "in": "query" + }, + { + "type": "integer", + "name": "script_id", "in": "query" } ], @@ -1456,7 +1448,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/announcement.LatestResponse" + "$ref": "#/definitions/similarity.ListPairsResponse" }, "msg": { "type": "string" @@ -1473,9 +1465,8 @@ var doc = `{ } } }, - "/audit-logs": { + "/admin/similarity/pairs/{id}": { "get": { - "description": "全局管理日志(公开,仅返回管理员删除的脚本)", "consumes": [ "application/json" ], @@ -1483,9 +1474,16 @@ var doc = `{ "application/json" ], "tags": [ - "audit/audit_log" + "similarity" + ], + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + } ], - "summary": "全局管理日志(公开,仅返回管理员删除的脚本)", "responses": { "200": { "description": "OK", @@ -1495,7 +1493,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/audit.ListResponse" + "$ref": "#/definitions/similarity.GetPairDetailResponse" }, "msg": { "type": "string" @@ -1512,9 +1510,8 @@ var doc = `{ } } }, - "/auth/forgot-password": { + "/admin/similarity/pairs/{id}/whitelist": { "post": { - "description": "忘记密码", "consumes": [ "application/json" ], @@ -1522,15 +1519,20 @@ var doc = `{ "application/json" ], "tags": [ - "auth" + "similarity" ], - "summary": "忘记密码", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.ForgotPasswordRequest" + "$ref": "#/definitions/similarity.AddPairWhitelistRequest" } } ], @@ -1543,7 +1545,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.ForgotPasswordResponse" + "$ref": "#/definitions/similarity.AddPairWhitelistResponse" }, "msg": { "type": "string" @@ -1558,11 +1560,8 @@ var doc = `{ } } } - } - }, - "/auth/login": { - "post": { - "description": "登录(支持邮箱或用户名)", + }, + "delete": { "consumes": [ "application/json" ], @@ -1570,15 +1569,20 @@ var doc = `{ "application/json" ], "tags": [ - "auth" + "similarity" ], - "summary": "登录(支持邮箱或用户名)", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.LoginRequest" + "$ref": "#/definitions/similarity.RemovePairWhitelistRequest" } } ], @@ -1591,7 +1595,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.LoginResponse" + "$ref": "#/definitions/similarity.RemovePairWhitelistResponse" }, "msg": { "type": "string" @@ -1608,9 +1612,8 @@ var doc = `{ } } }, - "/auth/oidc/bindconfirm": { - "post": { - "description": "POST /auth/oidc/bindconfirm — 登录已有账号并绑定 OIDC", + "/admin/similarity/suspects": { + "get": { "consumes": [ "application/json" ], @@ -1618,16 +1621,23 @@ var doc = `{ "application/json" ], "tags": [ - "auth/oidc_login" + "similarity" ], - "summary": "POST /auth/oidc/bindconfirm — 登录已有账号并绑定 OIDC", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/auth.OIDCBindConfirmRequest" - } + "type": "number", + "name": "min_jaccard", + "in": "query" + }, + { + "type": "number", + "name": "min_coverage", + "in": "query" + }, + { + "type": "integer", + "name": "status", + "in": "query" } ], "responses": { @@ -1639,7 +1649,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OIDCBindConfirmResponse" + "$ref": "#/definitions/similarity.ListSuspectsResponse" }, "msg": { "type": "string" @@ -1656,9 +1666,8 @@ var doc = `{ } } }, - "/auth/oidc/bindinfo": { + "/admin/similarity/whitelist": { "get": { - "description": "GET /auth/oidc/bindinfo — 获取待绑定的 OIDC 信息", "consumes": [ "application/json" ], @@ -1666,15 +1675,7 @@ var doc = `{ "application/json" ], "tags": [ - "auth/oidc_login" - ], - "summary": "GET /auth/oidc/bindinfo — 获取待绑定的 OIDC 信息", - "parameters": [ - { - "type": "string", - "name": "bind_token", - "in": "query" - } + "similarity" ], "responses": { "200": { @@ -1685,7 +1686,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OIDCBindInfoResponse" + "$ref": "#/definitions/similarity.ListPairWhitelistResponse" }, "msg": { "type": "string" @@ -1702,9 +1703,9 @@ var doc = `{ } } }, - "/auth/oidc/providers": { + "/admin/system-configs": { "get": { - "description": "GET /auth/oidc/providers — 获取可用的 OIDC 登录提供商列表(公开接口)", + "description": "获取系统配置", "consumes": [ "application/json" ], @@ -1712,9 +1713,16 @@ var doc = `{ "application/json" ], "tags": [ - "auth/oidc_login" + "admin" + ], + "summary": "获取系统配置", + "parameters": [ + { + "type": "string", + "name": "prefix", + "in": "query" + } ], - "summary": "GET /auth/oidc/providers — 获取可用的 OIDC 登录提供商列表(公开接口)", "responses": { "200": { "description": "OK", @@ -1724,7 +1732,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OIDCProvidersResponse" + "$ref": "#/definitions/admin.GetSystemConfigsResponse" }, "msg": { "type": "string" @@ -1739,11 +1747,9 @@ var doc = `{ } } } - } - }, - "/auth/oidc/register": { - "post": { - "description": "POST /auth/oidc/register — 注册新账号并绑定 OIDC", + }, + "put": { + "description": "更新系统配置", "consumes": [ "application/json" ], @@ -1751,15 +1757,15 @@ var doc = `{ "application/json" ], "tags": [ - "auth/oidc_login" + "admin" ], - "summary": "POST /auth/oidc/register — 注册新账号并绑定 OIDC", + "summary": "更新系统配置", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.OIDCRegisterAndBindRequest" + "$ref": "#/definitions/admin.UpdateSystemConfigsRequest" } } ], @@ -1772,7 +1778,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OIDCRegisterAndBindResponse" + "$ref": "#/definitions/admin.UpdateSystemConfigsResponse" }, "msg": { "type": "string" @@ -1789,9 +1795,9 @@ var doc = `{ } } }, - "/auth/register": { - "post": { - "description": "邮箱注册", + "/admin/users": { + "get": { + "description": "管理员获取用户列表", "consumes": [ "application/json" ], @@ -1799,16 +1805,14 @@ var doc = `{ "application/json" ], "tags": [ - "auth" + "admin" ], - "summary": "邮箱注册", + "summary": "管理员获取用户列表", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/auth.RegisterRequest" - } + "type": "string", + "name": "keyword", + "in": "query" } ], "responses": { @@ -1820,7 +1824,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.RegisterResponse" + "$ref": "#/definitions/admin.ListUsersResponse" }, "msg": { "type": "string" @@ -1837,25 +1841,31 @@ var doc = `{ } } }, - "/auth/reset-password": { - "post": { - "description": "重置密码", - "consumes": [ - "application/json" + "/admin/users/{id}/admin-level": { + "put": { + "description": "更新用户管理员等级", + "consumes": [ + "application/json" ], "produces": [ "application/json" ], "tags": [ - "auth" + "admin" ], - "summary": "重置密码", + "summary": "更新用户管理员等级", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.ResetPasswordRequest" + "$ref": "#/definitions/admin.UpdateUserAdminLevelRequest" } } ], @@ -1868,7 +1878,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.ResetPasswordResponse" + "$ref": "#/definitions/admin.UpdateUserAdminLevelResponse" }, "msg": { "type": "string" @@ -1885,9 +1895,9 @@ var doc = `{ } } }, - "/auth/send-register-code": { - "post": { - "description": "发送注册验证码", + "/admin/users/{id}/status": { + "put": { + "description": "更新用户状态(封禁/解封)", "consumes": [ "application/json" ], @@ -1895,15 +1905,21 @@ var doc = `{ "application/json" ], "tags": [ - "auth" + "admin" ], - "summary": "发送注册验证码", + "summary": "更新用户状态(封禁/解封)", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.SendRegisterCodeRequest" + "$ref": "#/definitions/admin.UpdateUserStatusRequest" } } ], @@ -1916,7 +1932,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.SendRegisterCodeResponse" + "$ref": "#/definitions/admin.UpdateUserStatusResponse" }, "msg": { "type": "string" @@ -1933,9 +1949,9 @@ var doc = `{ } } }, - "/auth/webauthn/credentials": { + "/announcements": { "get": { - "description": "列出用户的 WebAuthn 凭证", + "description": "公告列表", "consumes": [ "application/json" ], @@ -1943,9 +1959,16 @@ var doc = `{ "application/json" ], "tags": [ - "auth/webauthn" + "announcement" + ], + "summary": "公告列表", + "parameters": [ + { + "type": "string", + "name": "locale", + "in": "query" + } ], - "summary": "列出用户的 WebAuthn 凭证", "responses": { "200": { "description": "OK", @@ -1955,7 +1978,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnListCredentialsResponse" + "$ref": "#/definitions/announcement.ListResponse" }, "msg": { "type": "string" @@ -1972,9 +1995,9 @@ var doc = `{ } } }, - "/auth/webauthn/credentials/{credentialId}": { - "put": { - "description": "重命名凭证", + "/announcements/latest": { + "get": { + "description": "最新重要公告", "consumes": [ "application/json" ], @@ -1982,22 +2005,14 @@ var doc = `{ "application/json" ], "tags": [ - "auth/webauthn" + "announcement" ], - "summary": "重命名凭证", + "summary": "最新重要公告", "parameters": [ { - "type": "integer", - "name": "credentialId", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/auth.WebAuthnRenameCredentialRequest" - } + "type": "string", + "name": "locale", + "in": "query" } ], "responses": { @@ -2009,7 +2024,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnRenameCredentialResponse" + "$ref": "#/definitions/announcement.LatestResponse" }, "msg": { "type": "string" @@ -2024,9 +2039,11 @@ var doc = `{ } } } - }, - "delete": { - "description": "删除凭证", + } + }, + "/audit-logs": { + "get": { + "description": "全局管理日志(公开,仅返回管理员删除的脚本)", "consumes": [ "application/json" ], @@ -2034,24 +2051,9 @@ var doc = `{ "application/json" ], "tags": [ - "auth/webauthn" - ], - "summary": "删除凭证", - "parameters": [ - { - "type": "integer", - "name": "credentialId", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/auth.WebAuthnDeleteCredentialRequest" - } - } + "audit/audit_log" ], + "summary": "全局管理日志(公开,仅返回管理员删除的脚本)", "responses": { "200": { "description": "OK", @@ -2061,7 +2063,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnDeleteCredentialResponse" + "$ref": "#/definitions/audit.ListResponse" }, "msg": { "type": "string" @@ -2078,9 +2080,9 @@ var doc = `{ } } }, - "/auth/webauthn/login/begin": { + "/auth/forgot-password": { "post": { - "description": "2FA: 开始 WebAuthn 登录验证", + "description": "忘记密码", "consumes": [ "application/json" ], @@ -2088,15 +2090,15 @@ var doc = `{ "application/json" ], "tags": [ - "auth/webauthn" + "auth" ], - "summary": "2FA: 开始 WebAuthn 登录验证", + "summary": "忘记密码", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.WebAuthnLoginBeginRequest" + "$ref": "#/definitions/auth.ForgotPasswordRequest" } } ], @@ -2109,7 +2111,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnLoginBeginResponse" + "$ref": "#/definitions/auth.ForgotPasswordResponse" }, "msg": { "type": "string" @@ -2126,9 +2128,9 @@ var doc = `{ } } }, - "/auth/webauthn/login/finish": { + "/auth/login": { "post": { - "description": "2FA: 完成 WebAuthn 登录验证", + "description": "登录(支持邮箱或用户名)", "consumes": [ "application/json" ], @@ -2136,15 +2138,15 @@ var doc = `{ "application/json" ], "tags": [ - "auth/webauthn" + "auth" ], - "summary": "2FA: 完成 WebAuthn 登录验证", + "summary": "登录(支持邮箱或用户名)", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.WebAuthnLoginFinishRequest" + "$ref": "#/definitions/auth.LoginRequest" } } ], @@ -2157,7 +2159,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnLoginFinishResponse" + "$ref": "#/definitions/auth.LoginResponse" }, "msg": { "type": "string" @@ -2174,9 +2176,9 @@ var doc = `{ } } }, - "/auth/webauthn/passless/begin": { + "/auth/oidc/bindconfirm": { "post": { - "description": "无密码登录: 开始", + "description": "POST /auth/oidc/bindconfirm — 登录已有账号并绑定 OIDC", "consumes": [ "application/json" ], @@ -2184,15 +2186,15 @@ var doc = `{ "application/json" ], "tags": [ - "auth/webauthn" + "auth/oidc_login" ], - "summary": "无密码登录: 开始", + "summary": "POST /auth/oidc/bindconfirm — 登录已有账号并绑定 OIDC", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.WebAuthnPasswordlessBeginRequest" + "$ref": "#/definitions/auth.OIDCBindConfirmRequest" } } ], @@ -2205,7 +2207,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnPasswordlessBeginResponse" + "$ref": "#/definitions/auth.OIDCBindConfirmResponse" }, "msg": { "type": "string" @@ -2222,9 +2224,9 @@ var doc = `{ } } }, - "/auth/webauthn/passless/finish": { - "post": { - "description": "无密码登录: 完成", + "/auth/oidc/bindinfo": { + "get": { + "description": "GET /auth/oidc/bindinfo — 获取待绑定的 OIDC 信息", "consumes": [ "application/json" ], @@ -2232,16 +2234,14 @@ var doc = `{ "application/json" ], "tags": [ - "auth/webauthn" + "auth/oidc_login" ], - "summary": "无密码登录: 完成", + "summary": "GET /auth/oidc/bindinfo — 获取待绑定的 OIDC 信息", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/auth.WebAuthnPasswordlessFinishRequest" - } + "type": "string", + "name": "bind_token", + "in": "query" } ], "responses": { @@ -2253,7 +2253,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnPasswordlessFinishResponse" + "$ref": "#/definitions/auth.OIDCBindInfoResponse" }, "msg": { "type": "string" @@ -2270,9 +2270,9 @@ var doc = `{ } } }, - "/auth/webauthn/register/begin": { - "post": { - "description": "开始 WebAuthn 注册", + "/auth/oidc/providers": { + "get": { + "description": "GET /auth/oidc/providers — 获取可用的 OIDC 登录提供商列表(公开接口)", "consumes": [ "application/json" ], @@ -2280,18 +2280,9 @@ var doc = `{ "application/json" ], "tags": [ - "auth/webauthn" - ], - "summary": "开始 WebAuthn 注册", - "parameters": [ - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/auth.WebAuthnRegisterBeginRequest" - } - } + "auth/oidc_login" ], + "summary": "GET /auth/oidc/providers — 获取可用的 OIDC 登录提供商列表(公开接口)", "responses": { "200": { "description": "OK", @@ -2301,7 +2292,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnRegisterBeginResponse" + "$ref": "#/definitions/auth.OIDCProvidersResponse" }, "msg": { "type": "string" @@ -2318,9 +2309,9 @@ var doc = `{ } } }, - "/auth/webauthn/register/finish": { + "/auth/oidc/register": { "post": { - "description": "完成 WebAuthn 注册", + "description": "POST /auth/oidc/register — 注册新账号并绑定 OIDC", "consumes": [ "application/json" ], @@ -2328,15 +2319,15 @@ var doc = `{ "application/json" ], "tags": [ - "auth/webauthn" + "auth/oidc_login" ], - "summary": "完成 WebAuthn 注册", + "summary": "POST /auth/oidc/register — 注册新账号并绑定 OIDC", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.WebAuthnRegisterFinishRequest" + "$ref": "#/definitions/auth.OIDCRegisterAndBindRequest" } } ], @@ -2349,7 +2340,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnRegisterFinishResponse" + "$ref": "#/definitions/auth.OIDCRegisterAndBindResponse" }, "msg": { "type": "string" @@ -2366,9 +2357,9 @@ var doc = `{ } } }, - "/chat/sessions": { - "get": { - "description": "获取会话列表", + "/auth/register": { + "post": { + "description": "邮箱注册", "consumes": [ "application/json" ], @@ -2376,9 +2367,18 @@ var doc = `{ "application/json" ], "tags": [ - "chat" + "auth" + ], + "summary": "邮箱注册", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/auth.RegisterRequest" + } + } ], - "summary": "获取会话列表", "responses": { "200": { "description": "OK", @@ -2388,7 +2388,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/chat.ListSessionsResponse" + "$ref": "#/definitions/auth.RegisterResponse" }, "msg": { "type": "string" @@ -2403,9 +2403,11 @@ var doc = `{ } } } - }, + } + }, + "/auth/reset-password": { "post": { - "description": "创建会话", + "description": "重置密码", "consumes": [ "application/json" ], @@ -2413,15 +2415,15 @@ var doc = `{ "application/json" ], "tags": [ - "chat" + "auth" ], - "summary": "创建会话", + "summary": "重置密码", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/chat.CreateSessionRequest" + "$ref": "#/definitions/auth.ResetPasswordRequest" } } ], @@ -2434,7 +2436,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/chat.CreateSessionResponse" + "$ref": "#/definitions/auth.ResetPasswordResponse" }, "msg": { "type": "string" @@ -2451,9 +2453,9 @@ var doc = `{ } } }, - "/chat/sessions/{sessionId}": { - "delete": { - "description": "删除会话", + "/auth/send-register-code": { + "post": { + "description": "发送注册验证码", "consumes": [ "application/json" ], @@ -2461,21 +2463,15 @@ var doc = `{ "application/json" ], "tags": [ - "chat" + "auth" ], - "summary": "删除会话", + "summary": "发送注册验证码", "parameters": [ - { - "type": "integer", - "name": "sessionId", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/chat.DeleteSessionRequest" + "$ref": "#/definitions/auth.SendRegisterCodeRequest" } } ], @@ -2488,7 +2484,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/chat.DeleteSessionResponse" + "$ref": "#/definitions/auth.SendRegisterCodeResponse" }, "msg": { "type": "string" @@ -2505,9 +2501,9 @@ var doc = `{ } } }, - "/chat/sessions/{sessionId}/messages": { + "/auth/webauthn/credentials": { "get": { - "description": "获取会话消息列表", + "description": "列出用户的 WebAuthn 凭证", "consumes": [ "application/json" ], @@ -2515,17 +2511,9 @@ var doc = `{ "application/json" ], "tags": [ - "chat" - ], - "summary": "获取会话消息列表", - "parameters": [ - { - "type": "integer", - "name": "sessionId", - "in": "path", - "required": true - } + "auth/webauthn" ], + "summary": "列出用户的 WebAuthn 凭证", "responses": { "200": { "description": "OK", @@ -2535,7 +2523,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/chat.ListMessagesResponse" + "$ref": "#/definitions/auth.WebAuthnListCredentialsResponse" }, "msg": { "type": "string" @@ -2552,9 +2540,9 @@ var doc = `{ } } }, - "/favorites/folders": { - "get": { - "description": "收藏夹列表", + "/auth/webauthn/credentials/{credentialId}": { + "put": { + "description": "重命名凭证", "consumes": [ "application/json" ], @@ -2562,60 +2550,21 @@ var doc = `{ "application/json" ], "tags": [ - "script/favorite" + "auth/webauthn" ], - "summary": "收藏夹列表", + "summary": "重命名凭证", "parameters": [ { "type": "integer", - "description": "用户ID,0表示当前登录用户", - "name": "user_id", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "properties": { - "code": { - "type": "integer" - }, - "data": { - "$ref": "#/definitions/script.FavoriteFolderListResponse" - }, - "msg": { - "type": "string" - } - } - } + "name": "credentialId", + "in": "path", + "required": true }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/BadRequest" - } - } - } - }, - "post": { - "description": "创建收藏夹", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "script/favorite" - ], - "summary": "创建收藏夹", - "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.CreateFolderRequest" + "$ref": "#/definitions/auth.WebAuthnRenameCredentialRequest" } } ], @@ -2628,7 +2577,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.CreateFolderResponse" + "$ref": "#/definitions/auth.WebAuthnRenameCredentialResponse" }, "msg": { "type": "string" @@ -2643,11 +2592,9 @@ var doc = `{ } } } - } - }, - "/favorites/folders/{id}": { - "put": { - "description": "编辑收藏夹", + }, + "delete": { + "description": "删除凭证", "consumes": [ "application/json" ], @@ -2655,13 +2602,13 @@ var doc = `{ "application/json" ], "tags": [ - "script/favorite" + "auth/webauthn" ], - "summary": "编辑收藏夹", + "summary": "删除凭证", "parameters": [ { "type": "integer", - "name": "id", + "name": "credentialId", "in": "path", "required": true }, @@ -2669,7 +2616,7 @@ var doc = `{ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.EditFolderRequest" + "$ref": "#/definitions/auth.WebAuthnDeleteCredentialRequest" } } ], @@ -2682,7 +2629,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.EditFolderResponse" + "$ref": "#/definitions/auth.WebAuthnDeleteCredentialResponse" }, "msg": { "type": "string" @@ -2697,9 +2644,11 @@ var doc = `{ } } } - }, - "delete": { - "description": "删除收藏夹", + } + }, + "/auth/webauthn/login/begin": { + "post": { + "description": "2FA: 开始 WebAuthn 登录验证", "consumes": [ "application/json" ], @@ -2707,21 +2656,15 @@ var doc = `{ "application/json" ], "tags": [ - "script/favorite" + "auth/webauthn" ], - "summary": "删除收藏夹", + "summary": "2FA: 开始 WebAuthn 登录验证", "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.DeleteFolderRequest" + "$ref": "#/definitions/auth.WebAuthnLoginBeginRequest" } } ], @@ -2734,7 +2677,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.DeleteFolderResponse" + "$ref": "#/definitions/auth.WebAuthnLoginBeginResponse" }, "msg": { "type": "string" @@ -2751,9 +2694,9 @@ var doc = `{ } } }, - "/favorites/folders/{id}/detail": { - "get": { - "description": "收藏夹详情", + "/auth/webauthn/login/finish": { + "post": { + "description": "2FA: 完成 WebAuthn 登录验证", "consumes": [ "application/json" ], @@ -2761,15 +2704,16 @@ var doc = `{ "application/json" ], "tags": [ - "script/favorite" + "auth/webauthn" ], - "summary": "收藏夹详情", + "summary": "2FA: 完成 WebAuthn 登录验证", "parameters": [ { - "type": "integer", - "name": "id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/auth.WebAuthnLoginFinishRequest" + } } ], "responses": { @@ -2781,7 +2725,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.FavoriteFolderDetailResponse" + "$ref": "#/definitions/auth.WebAuthnLoginFinishResponse" }, "msg": { "type": "string" @@ -2798,9 +2742,9 @@ var doc = `{ } } }, - "/favorites/folders/{id}/favorite": { + "/auth/webauthn/passless/begin": { "post": { - "description": "收藏脚本", + "description": "无密码登录: 开始", "consumes": [ "application/json" ], @@ -2808,22 +2752,15 @@ var doc = `{ "application/json" ], "tags": [ - "script/favorite" + "auth/webauthn" ], - "summary": "收藏脚本", + "summary": "无密码登录: 开始", "parameters": [ - { - "type": "integer", - "description": "一次只能收藏到一个收藏夹", - "name": "id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.FavoriteScriptRequest" + "$ref": "#/definitions/auth.WebAuthnPasswordlessBeginRequest" } } ], @@ -2836,7 +2773,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.FavoriteScriptResponse" + "$ref": "#/definitions/auth.WebAuthnPasswordlessBeginResponse" }, "msg": { "type": "string" @@ -2851,9 +2788,11 @@ var doc = `{ } } } - }, - "delete": { - "description": "取消收藏脚本", + } + }, + "/auth/webauthn/passless/finish": { + "post": { + "description": "无密码登录: 完成", "consumes": [ "application/json" ], @@ -2861,22 +2800,15 @@ var doc = `{ "application/json" ], "tags": [ - "script/favorite" + "auth/webauthn" ], - "summary": "取消收藏脚本", + "summary": "无密码登录: 完成", "parameters": [ - { - "type": "integer", - "description": "一次只能从一个收藏夹移除,如果为0表示从所有收藏夹移除", - "name": "id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.UnfavoriteScriptRequest" + "$ref": "#/definitions/auth.WebAuthnPasswordlessFinishRequest" } } ], @@ -2889,7 +2821,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UnfavoriteScriptResponse" + "$ref": "#/definitions/auth.WebAuthnPasswordlessFinishResponse" }, "msg": { "type": "string" @@ -2906,9 +2838,9 @@ var doc = `{ } } }, - "/favorites/scripts": { - "get": { - "description": "获取收藏夹脚本列表", + "/auth/webauthn/register/begin": { + "post": { + "description": "开始 WebAuthn 注册", "consumes": [ "application/json" ], @@ -2916,21 +2848,16 @@ var doc = `{ "application/json" ], "tags": [ - "script/favorite" + "auth/webauthn" ], - "summary": "获取收藏夹脚本列表", + "summary": "开始 WebAuthn 注册", "parameters": [ { - "type": "integer", - "description": "收藏夹ID,0表示所有的收藏", - "name": "folder_id", - "in": "query" - }, - { - "type": "integer", - "description": "用户ID,0表示当前登录用户", - "name": "user_id", - "in": "query" + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/auth.WebAuthnRegisterBeginRequest" + } } ], "responses": { @@ -2942,7 +2869,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.FavoriteScriptListResponse" + "$ref": "#/definitions/auth.WebAuthnRegisterBeginResponse" }, "msg": { "type": "string" @@ -2959,9 +2886,9 @@ var doc = `{ } } }, - "/feedback": { + "/auth/webauthn/register/finish": { "post": { - "description": "用户反馈请求", + "description": "完成 WebAuthn 注册", "consumes": [ "application/json" ], @@ -2969,15 +2896,15 @@ var doc = `{ "application/json" ], "tags": [ - "system" + "auth/webauthn" ], - "summary": "用户反馈请求", + "summary": "完成 WebAuthn 注册", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/system.FeedbackRequest" + "$ref": "#/definitions/auth.WebAuthnRegisterFinishRequest" } } ], @@ -2990,7 +2917,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/system.FeedbackResponse" + "$ref": "#/definitions/auth.WebAuthnRegisterFinishResponse" }, "msg": { "type": "string" @@ -3007,9 +2934,9 @@ var doc = `{ } } }, - "/global-config": { + "/chat/sessions": { "get": { - "description": "获取全局配置(公开接口)", + "description": "获取会话列表", "consumes": [ "application/json" ], @@ -3017,9 +2944,9 @@ var doc = `{ "application/json" ], "tags": [ - "system" + "chat" ], - "summary": "获取全局配置(公开接口)", + "summary": "获取会话列表", "responses": { "200": { "description": "OK", @@ -3029,7 +2956,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/system.GetGlobalConfigResponse" + "$ref": "#/definitions/chat.ListSessionsResponse" }, "msg": { "type": "string" @@ -3044,11 +2971,9 @@ var doc = `{ } } } - } - }, - "/notifications": { - "get": { - "description": "获取通知列表", + }, + "post": { + "description": "创建会话", "consumes": [ "application/json" ], @@ -3056,15 +2981,16 @@ var doc = `{ "application/json" ], "tags": [ - "notification" + "chat" ], - "summary": "获取通知列表", + "summary": "创建会话", "parameters": [ { - "type": "integer", - "description": "0:全部 1:未读 2:已读", - "name": "read_status", - "in": "query" + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/chat.CreateSessionRequest" + } } ], "responses": { @@ -3076,7 +3002,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/notification.ListResponse" + "$ref": "#/definitions/chat.CreateSessionResponse" }, "msg": { "type": "string" @@ -3093,25 +3019,31 @@ var doc = `{ } } }, - "/notifications/read": { - "put": { - "description": "批量标记已读", - "consumes": [ - "application/json" + "/chat/sessions/{sessionId}": { + "delete": { + "description": "删除会话", + "consumes": [ + "application/json" ], "produces": [ "application/json" ], "tags": [ - "notification" + "chat" ], - "summary": "批量标记已读", + "summary": "删除会话", "parameters": [ + { + "type": "integer", + "name": "sessionId", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/notification.BatchMarkReadRequest" + "$ref": "#/definitions/chat.DeleteSessionRequest" } } ], @@ -3124,7 +3056,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/notification.BatchMarkReadResponse" + "$ref": "#/definitions/chat.DeleteSessionResponse" }, "msg": { "type": "string" @@ -3141,48 +3073,9 @@ var doc = `{ } } }, - "/notifications/unread-count": { + "/chat/sessions/{sessionId}/messages": { "get": { - "description": "获取未读通知数量", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "notification" - ], - "summary": "获取未读通知数量", - "responses": { - "200": { - "description": "OK", - "schema": { - "properties": { - "code": { - "type": "integer" - }, - "data": { - "$ref": "#/definitions/notification.GetUnreadCountResponse" - }, - "msg": { - "type": "string" - } - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/BadRequest" - } - } - } - } - }, - "/notifications/{id}/read": { - "put": { - "description": "标记通知为已读", + "description": "获取会话消息列表", "consumes": [ "application/json" ], @@ -3190,22 +3083,15 @@ var doc = `{ "application/json" ], "tags": [ - "notification" + "chat" ], - "summary": "标记通知为已读", + "summary": "获取会话消息列表", "parameters": [ { "type": "integer", - "name": "id", + "name": "sessionId", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/notification.MarkReadRequest" - } } ], "responses": { @@ -3217,7 +3103,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/notification.MarkReadResponse" + "$ref": "#/definitions/chat.ListMessagesResponse" }, "msg": { "type": "string" @@ -3234,9 +3120,9 @@ var doc = `{ } } }, - "/oauth/authorize": { + "/favorites/folders": { "get": { - "description": "GET /oauth/authorize — 授权页(重定向自客户端)", + "description": "收藏夹列表", "consumes": [ "application/json" ], @@ -3244,38 +3130,14 @@ var doc = `{ "application/json" ], "tags": [ - "auth/oauth2" + "script/favorite" ], - "summary": "GET /oauth/authorize — 授权页(重定向自客户端)", + "summary": "收藏夹列表", "parameters": [ { - "type": "string", - "name": "client_id", - "in": "query" - }, - { - "type": "string", - "name": "redirect_uri", - "in": "query" - }, - { - "type": "string", - "name": "response_type", - "in": "query" - }, - { - "type": "string", - "name": "scope", - "in": "query" - }, - { - "type": "string", - "name": "state", - "in": "query" - }, - { - "type": "string", - "name": "nonce", + "type": "integer", + "description": "用户ID,0表示当前登录用户", + "name": "user_id", "in": "query" } ], @@ -3288,7 +3150,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OAuth2AuthorizeResponse" + "$ref": "#/definitions/script.FavoriteFolderListResponse" }, "msg": { "type": "string" @@ -3305,7 +3167,7 @@ var doc = `{ } }, "post": { - "description": "POST /oauth/authorize — 用户批准授权", + "description": "创建收藏夹", "consumes": [ "application/json" ], @@ -3313,15 +3175,15 @@ var doc = `{ "application/json" ], "tags": [ - "auth/oauth2" + "script/favorite" ], - "summary": "POST /oauth/authorize — 用户批准授权", + "summary": "创建收藏夹", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.OAuth2ApproveRequest" + "$ref": "#/definitions/script.CreateFolderRequest" } } ], @@ -3334,7 +3196,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OAuth2ApproveResponse" + "$ref": "#/definitions/script.CreateFolderResponse" }, "msg": { "type": "string" @@ -3351,9 +3213,9 @@ var doc = `{ } } }, - "/oauth/jwks": { - "get": { - "description": "GET /oauth/jwks", + "/favorites/folders/{id}": { + "put": { + "description": "编辑收藏夹", "consumes": [ "application/json" ], @@ -3361,9 +3223,24 @@ var doc = `{ "application/json" ], "tags": [ - "auth/oidc" + "script/favorite" + ], + "summary": "编辑收藏夹", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.EditFolderRequest" + } + } ], - "summary": "GET /oauth/jwks", "responses": { "200": { "description": "OK", @@ -3373,7 +3250,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OIDCJWKSResponse" + "$ref": "#/definitions/script.EditFolderResponse" }, "msg": { "type": "string" @@ -3388,11 +3265,9 @@ var doc = `{ } } } - } - }, - "/oauth/token": { - "post": { - "description": "POST /oauth/token — 用 code 换 access_token", + }, + "delete": { + "description": "删除收藏夹", "consumes": [ "application/json" ], @@ -3400,15 +3275,21 @@ var doc = `{ "application/json" ], "tags": [ - "auth/oauth2" + "script/favorite" ], - "summary": "POST /oauth/token — 用 code 换 access_token", + "summary": "删除收藏夹", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.OAuth2TokenRequest" + "$ref": "#/definitions/script.DeleteFolderRequest" } } ], @@ -3421,7 +3302,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OAuth2TokenResponse" + "$ref": "#/definitions/script.DeleteFolderResponse" }, "msg": { "type": "string" @@ -3438,9 +3319,9 @@ var doc = `{ } } }, - "/oauth/userinfo": { + "/favorites/folders/{id}/detail": { "get": { - "description": "GET /oauth/userinfo — 获取用户信息", + "description": "收藏夹详情", "consumes": [ "application/json" ], @@ -3448,9 +3329,17 @@ var doc = `{ "application/json" ], "tags": [ - "auth/oauth2" + "script/favorite" + ], + "summary": "收藏夹详情", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + } ], - "summary": "GET /oauth/userinfo — 获取用户信息", "responses": { "200": { "description": "OK", @@ -3460,7 +3349,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OAuth2UserInfoResponse" + "$ref": "#/definitions/script.FavoriteFolderDetailResponse" }, "msg": { "type": "string" @@ -3477,9 +3366,9 @@ var doc = `{ } } }, - "/open/crx-download/{id}": { - "get": { - "description": "谷歌crx下载服务", + "/favorites/folders/{id}/favorite": { + "post": { + "description": "收藏脚本", "consumes": [ "application/json" ], @@ -3487,15 +3376,23 @@ var doc = `{ "application/json" ], "tags": [ - "open" + "script/favorite" ], - "summary": "谷歌crx下载服务", + "summary": "收藏脚本", "parameters": [ { "type": "integer", + "description": "一次只能收藏到一个收藏夹", "name": "id", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.FavoriteScriptRequest" + } } ], "responses": { @@ -3507,7 +3404,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/open.CrxDownloadResponse" + "$ref": "#/definitions/script.FavoriteScriptResponse" }, "msg": { "type": "string" @@ -3522,11 +3419,9 @@ var doc = `{ } } } - } - }, - "/resource/image": { - "post": { - "description": "上传图片", + }, + "delete": { + "description": "取消收藏脚本", "consumes": [ "application/json" ], @@ -3534,15 +3429,22 @@ var doc = `{ "application/json" ], "tags": [ - "resource" + "script/favorite" ], - "summary": "上传图片", + "summary": "取消收藏脚本", "parameters": [ + { + "type": "integer", + "description": "一次只能从一个收藏夹移除,如果为0表示从所有收藏夹移除", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/resource.UploadImageRequest" + "$ref": "#/definitions/script.UnfavoriteScriptRequest" } } ], @@ -3555,7 +3457,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/resource.UploadImageResponse" + "$ref": "#/definitions/script.UnfavoriteScriptResponse" }, "msg": { "type": "string" @@ -3572,9 +3474,9 @@ var doc = `{ } } }, - "/script/{id}/statistics": { + "/favorites/scripts": { "get": { - "description": "脚本统计数据", + "description": "获取收藏夹脚本列表", "consumes": [ "application/json" ], @@ -3582,27 +3484,33 @@ var doc = `{ "application/json" ], "tags": [ - "statistics" + "script/favorite" ], - "summary": "脚本统计数据", + "summary": "获取收藏夹脚本列表", "parameters": [ { "type": "integer", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "properties": { - "code": { + "description": "收藏夹ID,0表示所有的收藏", + "name": "folder_id", + "in": "query" + }, + { + "type": "integer", + "description": "用户ID,0表示当前登录用户", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { "type": "integer" }, "data": { - "$ref": "#/definitions/statistics.ScriptResponse" + "$ref": "#/definitions/script.FavoriteScriptListResponse" }, "msg": { "type": "string" @@ -3619,9 +3527,9 @@ var doc = `{ } } }, - "/script/{id}/statistics/realtime": { - "get": { - "description": "脚本实时统计数据", + "/feedback": { + "post": { + "description": "用户反馈请求", "consumes": [ "application/json" ], @@ -3629,15 +3537,16 @@ var doc = `{ "application/json" ], "tags": [ - "statistics" + "system" ], - "summary": "脚本实时统计数据", + "summary": "用户反馈请求", "parameters": [ { - "type": "integer", - "name": "id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/system.FeedbackRequest" + } } ], "responses": { @@ -3649,7 +3558,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/statistics.ScriptRealtimeResponse" + "$ref": "#/definitions/system.FeedbackResponse" }, "msg": { "type": "string" @@ -3666,9 +3575,9 @@ var doc = `{ } } }, - "/scripts": { + "/global-config": { "get": { - "description": "获取脚本列表", + "description": "获取全局配置(公开接口)", "consumes": [ "application/json" ], @@ -3676,44 +3585,9 @@ var doc = `{ "application/json" ], "tags": [ - "script" - ], - "summary": "获取脚本列表", - "parameters": [ - { - "type": "string", - "name": "keyword", - "in": "query" - }, - { - "type": "string", - "name": "domain", - "in": "query" - }, - { - "type": "integer", - "description": "用户ID", - "name": "user_id", - "in": "query" - }, - { - "type": "integer", - "description": "分类ID", - "name": "category", - "in": "query" - }, - { - "type": "integer", - "description": "0:全部 1: 脚本 2: 库 3: 后台脚本 4: 定时脚本", - "name": "script_type,default=0", - "in": "query" - }, - { - "type": "string", - "name": "sort,default=today_download", - "in": "query" - } + "system" ], + "summary": "获取全局配置(公开接口)", "responses": { "200": { "description": "OK", @@ -3723,7 +3597,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.ListResponse" + "$ref": "#/definitions/system.GetGlobalConfigResponse" }, "msg": { "type": "string" @@ -3738,9 +3612,11 @@ var doc = `{ } } } - }, - "post": { - "description": "创建脚本", + } + }, + "/notifications": { + "get": { + "description": "获取通知列表", "consumes": [ "application/json" ], @@ -3748,16 +3624,15 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "notification" ], - "summary": "创建脚本", + "summary": "获取通知列表", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.CreateRequest" - } + "type": "integer", + "description": "0:全部 1:未读 2:已读", + "name": "read_status", + "in": "query" } ], "responses": { @@ -3769,7 +3644,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.CreateResponse" + "$ref": "#/definitions/notification.ListResponse" }, "msg": { "type": "string" @@ -3786,9 +3661,9 @@ var doc = `{ } } }, - "/scripts/:id/gray": { + "/notifications/read": { "put": { - "description": "更新脚本灰度策略", + "description": "批量标记已读", "consumes": [ "application/json" ], @@ -3796,15 +3671,15 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "notification" ], - "summary": "更新脚本灰度策略", + "summary": "批量标记已读", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.UpdateScriptGrayRequest" + "$ref": "#/definitions/notification.BatchMarkReadRequest" } } ], @@ -3817,7 +3692,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateScriptGrayResponse" + "$ref": "#/definitions/notification.BatchMarkReadResponse" }, "msg": { "type": "string" @@ -3834,9 +3709,9 @@ var doc = `{ } } }, - "/scripts/:id/lib-info": { - "put": { - "description": "更新库信息", + "/notifications/unread-count": { + "get": { + "description": "获取未读通知数量", "consumes": [ "application/json" ], @@ -3844,18 +3719,9 @@ var doc = `{ "application/json" ], "tags": [ - "script" - ], - "summary": "更新库信息", - "parameters": [ - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.UpdateLibInfoRequest" - } - } + "notification" ], + "summary": "获取未读通知数量", "responses": { "200": { "description": "OK", @@ -3865,7 +3731,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateLibInfoResponse" + "$ref": "#/definitions/notification.GetUnreadCountResponse" }, "msg": { "type": "string" @@ -3882,9 +3748,9 @@ var doc = `{ } } }, - "/scripts/category": { - "get": { - "description": "脚本分类列表", + "/notifications/{id}/read": { + "put": { + "description": "标记通知为已读", "consumes": [ "application/json" ], @@ -3892,25 +3758,22 @@ var doc = `{ "application/json" ], "tags": [ - "script/category" + "notification" ], - "summary": "脚本分类列表", + "summary": "标记通知为已读", "parameters": [ { - "type": "string", - "description": "前缀", - "name": "prefix", - "in": "query" + "type": "integer", + "name": "id", + "in": "path", + "required": true }, { - "enum": [ - 1, - 2 - ], - "type": "integer", - "description": "分类类型: 1: 脚本分类, 2: Tag\nScriptCategoryType enum type:\n- ScriptCategoryTypeCategory: 1\n- ScriptCategoryTypeTag: 2", - "name": "type", - "in": "query" + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/notification.MarkReadRequest" + } } ], "responses": { @@ -3922,7 +3785,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.CategoryListResponse" + "$ref": "#/definitions/notification.MarkReadResponse" }, "msg": { "type": "string" @@ -3939,9 +3802,9 @@ var doc = `{ } } }, - "/scripts/invite/{code}": { + "/oauth/authorize": { "get": { - "description": "邀请码信息", + "description": "GET /oauth/authorize — 授权页(重定向自客户端)", "consumes": [ "application/json" ], @@ -3949,15 +3812,39 @@ var doc = `{ "application/json" ], "tags": [ - "script/access_invite" + "auth/oauth2" ], - "summary": "邀请码信息", + "summary": "GET /oauth/authorize — 授权页(重定向自客户端)", "parameters": [ { "type": "string", - "name": "code", - "in": "path", - "required": true + "name": "client_id", + "in": "query" + }, + { + "type": "string", + "name": "redirect_uri", + "in": "query" + }, + { + "type": "string", + "name": "response_type", + "in": "query" + }, + { + "type": "string", + "name": "scope", + "in": "query" + }, + { + "type": "string", + "name": "state", + "in": "query" + }, + { + "type": "string", + "name": "nonce", + "in": "query" } ], "responses": { @@ -3969,7 +3856,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.InviteCodeInfoResponse" + "$ref": "#/definitions/auth.OAuth2AuthorizeResponse" }, "msg": { "type": "string" @@ -3984,11 +3871,9 @@ var doc = `{ } } } - } - }, - "/scripts/invite/{code}/accept": { - "put": { - "description": "接受邀请", + }, + "post": { + "description": "POST /oauth/authorize — 用户批准授权", "consumes": [ "application/json" ], @@ -3996,21 +3881,15 @@ var doc = `{ "application/json" ], "tags": [ - "script/access_invite" + "auth/oauth2" ], - "summary": "接受邀请", + "summary": "POST /oauth/authorize — 用户批准授权", "parameters": [ - { - "type": "string", - "name": "code", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.AcceptInviteRequest" + "$ref": "#/definitions/auth.OAuth2ApproveRequest" } } ], @@ -4023,7 +3902,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.AcceptInviteResponse" + "$ref": "#/definitions/auth.OAuth2ApproveResponse" }, "msg": { "type": "string" @@ -4040,9 +3919,9 @@ var doc = `{ } } }, - "/scripts/last-score": { + "/oauth/jwks": { "get": { - "description": "最新评分脚本", + "description": "GET /oauth/jwks", "consumes": [ "application/json" ], @@ -4050,9 +3929,9 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "auth/oidc" ], - "summary": "最新评分脚本", + "summary": "GET /oauth/jwks", "responses": { "200": { "description": "OK", @@ -4062,7 +3941,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.LastScoreResponse" + "$ref": "#/definitions/auth.OIDCJWKSResponse" }, "msg": { "type": "string" @@ -4079,9 +3958,9 @@ var doc = `{ } } }, - "/scripts/migrate/es": { + "/oauth/token": { "post": { - "description": "全量迁移数据到es", + "description": "POST /oauth/token — 用 code 换 access_token", "consumes": [ "application/json" ], @@ -4089,15 +3968,15 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "auth/oauth2" ], - "summary": "全量迁移数据到es", + "summary": "POST /oauth/token — 用 code 换 access_token", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.MigrateEsRequest" + "$ref": "#/definitions/auth.OAuth2TokenRequest" } } ], @@ -4110,7 +3989,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.MigrateEsResponse" + "$ref": "#/definitions/auth.OAuth2TokenResponse" }, "msg": { "type": "string" @@ -4127,9 +4006,9 @@ var doc = `{ } } }, - "/scripts/{id}": { + "/oauth/userinfo": { "get": { - "description": "获取脚本信息", + "description": "GET /oauth/userinfo — 获取用户信息", "consumes": [ "application/json" ], @@ -4137,17 +4016,9 @@ var doc = `{ "application/json" ], "tags": [ - "script" - ], - "summary": "获取脚本信息", - "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - } + "auth/oauth2" ], + "summary": "GET /oauth/userinfo — 获取用户信息", "responses": { "200": { "description": "OK", @@ -4157,7 +4028,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.InfoResponse" + "$ref": "#/definitions/auth.OAuth2UserInfoResponse" }, "msg": { "type": "string" @@ -4172,9 +4043,11 @@ var doc = `{ } } } - }, - "delete": { - "description": "删除脚本", + } + }, + "/open/crx-download/{id}": { + "get": { + "description": "谷歌crx下载服务", "consumes": [ "application/json" ], @@ -4182,22 +4055,15 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "open" ], - "summary": "删除脚本", + "summary": "谷歌crx下载服务", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.DeleteRequest" - } } ], "responses": { @@ -4209,7 +4075,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.DeleteResponse" + "$ref": "#/definitions/open.CrxDownloadResponse" }, "msg": { "type": "string" @@ -4226,9 +4092,9 @@ var doc = `{ } } }, - "/scripts/{id}/access": { - "get": { - "description": "访问控制列表", + "/resource/image": { + "post": { + "description": "上传图片", "consumes": [ "application/json" ], @@ -4236,15 +4102,16 @@ var doc = `{ "application/json" ], "tags": [ - "script/access" + "resource" ], - "summary": "访问控制列表", + "summary": "上传图片", "parameters": [ { - "type": "integer", - "name": "id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/resource.UploadImageRequest" + } } ], "responses": { @@ -4256,7 +4123,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.AccessListResponse" + "$ref": "#/definitions/resource.UploadImageResponse" }, "msg": { "type": "string" @@ -4273,9 +4140,9 @@ var doc = `{ } } }, - "/scripts/{id}/access/group": { - "post": { - "description": "添加组权限", + "/script/{id}/statistics": { + "get": { + "description": "脚本统计数据", "consumes": [ "application/json" ], @@ -4283,22 +4150,15 @@ var doc = `{ "application/json" ], "tags": [ - "script/access" + "statistics" ], - "summary": "添加组权限", + "summary": "脚本统计数据", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.AddGroupAccessRequest" - } } ], "responses": { @@ -4310,7 +4170,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.AddGroupAccessResponse" + "$ref": "#/definitions/statistics.ScriptResponse" }, "msg": { "type": "string" @@ -4327,9 +4187,9 @@ var doc = `{ } } }, - "/scripts/{id}/access/user": { - "post": { - "description": "添加用户权限, 通过用户名进行邀请", + "/script/{id}/statistics/realtime": { + "get": { + "description": "脚本实时统计数据", "consumes": [ "application/json" ], @@ -4337,22 +4197,15 @@ var doc = `{ "application/json" ], "tags": [ - "script/access" + "statistics" ], - "summary": "添加用户权限, 通过用户名进行邀请", + "summary": "脚本实时统计数据", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.AddUserAccessRequest" - } } ], "responses": { @@ -4364,7 +4217,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.AddUserAccessResponse" + "$ref": "#/definitions/statistics.ScriptRealtimeResponse" }, "msg": { "type": "string" @@ -4381,9 +4234,9 @@ var doc = `{ } } }, - "/scripts/{id}/access/{aid}": { - "put": { - "description": "更新访问控制", + "/scripts": { + "get": { + "description": "获取脚本列表", "consumes": [ "application/json" ], @@ -4391,28 +4244,42 @@ var doc = `{ "application/json" ], "tags": [ - "script/access" + "script" ], - "summary": "更新访问控制", + "summary": "获取脚本列表", "parameters": [ + { + "type": "string", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "name": "domain", + "in": "query" + }, { "type": "integer", - "name": "id", - "in": "path", - "required": true + "description": "用户ID", + "name": "user_id", + "in": "query" }, { "type": "integer", - "name": "aid", - "in": "path", - "required": true + "description": "分类ID", + "name": "category", + "in": "query" }, { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.UpdateAccessRequest" - } + "type": "integer", + "description": "0:全部 1: 脚本 2: 库 3: 后台脚本 4: 定时脚本", + "name": "script_type,default=0", + "in": "query" + }, + { + "type": "string", + "name": "sort,default=today_download", + "in": "query" } ], "responses": { @@ -4424,7 +4291,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateAccessResponse" + "$ref": "#/definitions/script.ListResponse" }, "msg": { "type": "string" @@ -4440,8 +4307,8 @@ var doc = `{ } } }, - "delete": { - "description": "删除访问控制", + "post": { + "description": "创建脚本", "consumes": [ "application/json" ], @@ -4449,27 +4316,15 @@ var doc = `{ "application/json" ], "tags": [ - "script/access" + "script" ], - "summary": "删除访问控制", + "summary": "创建脚本", "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "name": "aid", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.DeleteAccessRequest" + "$ref": "#/definitions/script.CreateRequest" } } ], @@ -4482,7 +4337,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.DeleteAccessResponse" + "$ref": "#/definitions/script.CreateResponse" }, "msg": { "type": "string" @@ -4499,9 +4354,9 @@ var doc = `{ } } }, - "/scripts/{id}/archive": { + "/scripts/:id/gray": { "put": { - "description": "归档脚本", + "description": "更新脚本灰度策略", "consumes": [ "application/json" ], @@ -4511,19 +4366,13 @@ var doc = `{ "tags": [ "script" ], - "summary": "归档脚本", + "summary": "更新脚本灰度策略", "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.ArchiveRequest" + "$ref": "#/definitions/script.UpdateScriptGrayRequest" } } ], @@ -4536,7 +4385,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.ArchiveResponse" + "$ref": "#/definitions/script.UpdateScriptGrayResponse" }, "msg": { "type": "string" @@ -4553,9 +4402,9 @@ var doc = `{ } } }, - "/scripts/{id}/audit-logs": { - "get": { - "description": "单脚本日志(需要脚本 manage 权限)", + "/scripts/:id/lib-info": { + "put": { + "description": "更新库信息", "consumes": [ "application/json" ], @@ -4563,15 +4412,16 @@ var doc = `{ "application/json" ], "tags": [ - "audit/audit_log" + "script" ], - "summary": "单脚本日志(需要脚本 manage 权限)", + "summary": "更新库信息", "parameters": [ { - "type": "integer", - "name": "id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.UpdateLibInfoRequest" + } } ], "responses": { @@ -4583,7 +4433,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/audit.ScriptListResponse" + "$ref": "#/definitions/script.UpdateLibInfoResponse" }, "msg": { "type": "string" @@ -4600,9 +4450,9 @@ var doc = `{ } } }, - "/scripts/{id}/code": { + "/scripts/category": { "get": { - "description": "获取脚本代码信息", + "description": "脚本分类列表", "consumes": [ "application/json" ], @@ -4610,15 +4460,25 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "script/category" ], - "summary": "获取脚本代码信息", + "summary": "脚本分类列表", "parameters": [ { + "type": "string", + "description": "前缀", + "name": "prefix", + "in": "query" + }, + { + "enum": [ + 1, + 2 + ], "type": "integer", - "name": "id", - "in": "path", - "required": true + "description": "分类类型: 1: 脚本分类, 2: Tag\nScriptCategoryType enum type:\n- ScriptCategoryTypeCategory: 1\n- ScriptCategoryTypeTag: 2", + "name": "type", + "in": "query" } ], "responses": { @@ -4630,7 +4490,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.CodeResponse" + "$ref": "#/definitions/script.CategoryListResponse" }, "msg": { "type": "string" @@ -4645,9 +4505,11 @@ var doc = `{ } } } - }, - "put": { - "description": "更新脚本/库代码", + } + }, + "/scripts/invite/{code}": { + "get": { + "description": "邀请码信息", "consumes": [ "application/json" ], @@ -4655,22 +4517,15 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "script/access_invite" ], - "summary": "更新脚本/库代码", + "summary": "邀请码信息", "parameters": [ { - "type": "integer", - "name": "id", + "type": "string", + "name": "code", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.UpdateCodeRequest" - } } ], "responses": { @@ -4682,7 +4537,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateCodeResponse" + "$ref": "#/definitions/script.InviteCodeInfoResponse" }, "msg": { "type": "string" @@ -4699,9 +4554,9 @@ var doc = `{ } } }, - "/scripts/{id}/code/{codeId}": { + "/scripts/invite/{code}/accept": { "put": { - "description": "更新脚本设置", + "description": "接受邀请", "consumes": [ "application/json" ], @@ -4709,19 +4564,13 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "script/access_invite" ], - "summary": "更新脚本设置", + "summary": "接受邀请", "parameters": [ { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "name": "codeId", + "type": "string", + "name": "code", "in": "path", "required": true }, @@ -4729,7 +4578,7 @@ var doc = `{ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.UpdateCodeSettingRequest" + "$ref": "#/definitions/script.AcceptInviteRequest" } } ], @@ -4742,7 +4591,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateCodeSettingResponse" + "$ref": "#/definitions/script.AcceptInviteResponse" }, "msg": { "type": "string" @@ -4757,9 +4606,11 @@ var doc = `{ } } } - }, - "delete": { - "description": "删除脚本/库代码", + } + }, + "/scripts/last-score": { + "get": { + "description": "最新评分脚本", "consumes": [ "application/json" ], @@ -4769,28 +4620,7 @@ var doc = `{ "tags": [ "script" ], - "summary": "删除脚本/库代码", - "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "name": "codeId", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.DeleteCodeRequest" - } - } - ], + "summary": "最新评分脚本", "responses": { "200": { "description": "OK", @@ -4800,7 +4630,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.DeleteCodeResponse" + "$ref": "#/definitions/script.LastScoreResponse" }, "msg": { "type": "string" @@ -4817,8 +4647,9 @@ var doc = `{ } } }, - "/scripts/{id}/commentReply": { - "put": { + "/scripts/migrate/es": { + "post": { + "description": "全量迁移数据到es", "consumes": [ "application/json" ], @@ -4826,20 +4657,15 @@ var doc = `{ "application/json" ], "tags": [ - "script/score" + "script" ], + "summary": "全量迁移数据到es", "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.ReplyScoreRequest" + "$ref": "#/definitions/script.MigrateEsRequest" } } ], @@ -4852,7 +4678,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.ReplyScoreResponse" + "$ref": "#/definitions/script.MigrateEsResponse" }, "msg": { "type": "string" @@ -4869,9 +4695,9 @@ var doc = `{ } } }, - "/scripts/{id}/group": { + "/scripts/{id}": { "get": { - "description": "群组列表", + "description": "获取脚本信息", "consumes": [ "application/json" ], @@ -4879,20 +4705,15 @@ var doc = `{ "application/json" ], "tags": [ - "script/group" + "script" ], - "summary": "群组列表", + "summary": "获取脚本信息", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "type": "string", - "name": "query", - "in": "query" } ], "responses": { @@ -4904,7 +4725,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.GroupListResponse" + "$ref": "#/definitions/script.InfoResponse" }, "msg": { "type": "string" @@ -4920,8 +4741,8 @@ var doc = `{ } } }, - "post": { - "description": "创建群组", + "delete": { + "description": "删除脚本", "consumes": [ "application/json" ], @@ -4929,9 +4750,9 @@ var doc = `{ "application/json" ], "tags": [ - "script/group" + "script" ], - "summary": "创建群组", + "summary": "删除脚本", "parameters": [ { "type": "integer", @@ -4943,7 +4764,7 @@ var doc = `{ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.CreateGroupRequest" + "$ref": "#/definitions/script.DeleteRequest" } } ], @@ -4956,7 +4777,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.CreateGroupResponse" + "$ref": "#/definitions/script.DeleteResponse" }, "msg": { "type": "string" @@ -4973,9 +4794,9 @@ var doc = `{ } } }, - "/scripts/{id}/group/{gid}": { - "put": { - "description": "更新群组", + "/scripts/{id}/access": { + "get": { + "description": "访问控制列表", "consumes": [ "application/json" ], @@ -4983,28 +4804,15 @@ var doc = `{ "application/json" ], "tags": [ - "script/group" + "script/access" ], - "summary": "更新群组", + "summary": "访问控制列表", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "type": "integer", - "name": "gid", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.UpdateGroupRequest" - } } ], "responses": { @@ -5016,7 +4824,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateGroupResponse" + "$ref": "#/definitions/script.AccessListResponse" }, "msg": { "type": "string" @@ -5031,9 +4839,11 @@ var doc = `{ } } } - }, - "delete": { - "description": "删除群组", + } + }, + "/scripts/{id}/access/group": { + "post": { + "description": "添加组权限", "consumes": [ "application/json" ], @@ -5041,9 +4851,9 @@ var doc = `{ "application/json" ], "tags": [ - "script/group" + "script/access" ], - "summary": "删除群组", + "summary": "添加组权限", "parameters": [ { "type": "integer", @@ -5051,17 +4861,11 @@ var doc = `{ "in": "path", "required": true }, - { - "type": "integer", - "name": "gid", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.DeleteGroupRequest" + "$ref": "#/definitions/script.AddGroupAccessRequest" } } ], @@ -5074,7 +4878,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.DeleteGroupResponse" + "$ref": "#/definitions/script.AddGroupAccessResponse" }, "msg": { "type": "string" @@ -5091,9 +4895,9 @@ var doc = `{ } } }, - "/scripts/{id}/group/{gid}/member": { - "get": { - "description": "群组成员列表", + "/scripts/{id}/access/user": { + "post": { + "description": "添加用户权限, 通过用户名进行邀请", "consumes": [ "application/json" ], @@ -5101,9 +4905,9 @@ var doc = `{ "application/json" ], "tags": [ - "script/group" + "script/access" ], - "summary": "群组成员列表", + "summary": "添加用户权限, 通过用户名进行邀请", "parameters": [ { "type": "integer", @@ -5112,10 +4916,11 @@ var doc = `{ "required": true }, { - "type": "integer", - "name": "gid", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.AddUserAccessRequest" + } } ], "responses": { @@ -5127,7 +4932,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.GroupMemberListResponse" + "$ref": "#/definitions/script.AddUserAccessResponse" }, "msg": { "type": "string" @@ -5142,9 +4947,11 @@ var doc = `{ } } } - }, - "post": { - "description": "添加成员", + } + }, + "/scripts/{id}/access/{aid}": { + "put": { + "description": "更新访问控制", "consumes": [ "application/json" ], @@ -5152,9 +4959,9 @@ var doc = `{ "application/json" ], "tags": [ - "script/group" + "script/access" ], - "summary": "添加成员", + "summary": "更新访问控制", "parameters": [ { "type": "integer", @@ -5164,7 +4971,7 @@ var doc = `{ }, { "type": "integer", - "name": "gid", + "name": "aid", "in": "path", "required": true }, @@ -5172,7 +4979,7 @@ var doc = `{ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.AddMemberRequest" + "$ref": "#/definitions/script.UpdateAccessRequest" } } ], @@ -5185,7 +4992,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.AddMemberResponse" + "$ref": "#/definitions/script.UpdateAccessResponse" }, "msg": { "type": "string" @@ -5200,11 +5007,9 @@ var doc = `{ } } } - } - }, - "/scripts/{id}/group/{gid}/member/{mid}": { - "put": { - "description": "更新成员", + }, + "delete": { + "description": "删除访问控制", "consumes": [ "application/json" ], @@ -5212,16 +5017,10 @@ var doc = `{ "application/json" ], "tags": [ - "script/group" + "script/access" ], - "summary": "更新成员", + "summary": "删除访问控制", "parameters": [ - { - "type": "integer", - "name": "mid", - "in": "path", - "required": true - }, { "type": "integer", "name": "id", @@ -5230,7 +5029,7 @@ var doc = `{ }, { "type": "integer", - "name": "gid", + "name": "aid", "in": "path", "required": true }, @@ -5238,7 +5037,7 @@ var doc = `{ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.UpdateMemberRequest" + "$ref": "#/definitions/script.DeleteAccessRequest" } } ], @@ -5251,7 +5050,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateMemberResponse" + "$ref": "#/definitions/script.DeleteAccessResponse" }, "msg": { "type": "string" @@ -5266,9 +5065,11 @@ var doc = `{ } } } - }, - "delete": { - "description": "移除成员", + } + }, + "/scripts/{id}/archive": { + "put": { + "description": "归档脚本", "consumes": [ "application/json" ], @@ -5276,9 +5077,9 @@ var doc = `{ "application/json" ], "tags": [ - "script/group" + "script" ], - "summary": "移除成员", + "summary": "归档脚本", "parameters": [ { "type": "integer", @@ -5286,23 +5087,11 @@ var doc = `{ "in": "path", "required": true }, - { - "type": "integer", - "name": "gid", - "in": "path", - "required": true - }, - { - "type": "integer", - "name": "mid", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.RemoveMemberRequest" + "$ref": "#/definitions/script.ArchiveRequest" } } ], @@ -5315,7 +5104,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.RemoveMemberResponse" + "$ref": "#/definitions/script.ArchiveResponse" }, "msg": { "type": "string" @@ -5332,9 +5121,9 @@ var doc = `{ } } }, - "/scripts/{id}/invite/code": { + "/scripts/{id}/audit-logs": { "get": { - "description": "邀请码列表", + "description": "单脚本日志(需要脚本 manage 权限)", "consumes": [ "application/json" ], @@ -5342,9 +5131,9 @@ var doc = `{ "application/json" ], "tags": [ - "script/access_invite" + "audit/audit_log" ], - "summary": "邀请码列表", + "summary": "单脚本日志(需要脚本 manage 权限)", "parameters": [ { "type": "integer", @@ -5362,7 +5151,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.InviteCodeListResponse" + "$ref": "#/definitions/audit.ScriptListResponse" }, "msg": { "type": "string" @@ -5377,32 +5166,27 @@ var doc = `{ } } } - }, - "post": { - "description": "创建邀请码", - "consumes": [ + } + }, + "/scripts/{id}/code": { + "get": { + "description": "获取脚本代码信息", + "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ - "script/access_invite" + "script" ], - "summary": "创建邀请码", + "summary": "获取脚本代码信息", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.CreateInviteCodeRequest" - } } ], "responses": { @@ -5414,7 +5198,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.CreateInviteCodeResponse" + "$ref": "#/definitions/script.CodeResponse" }, "msg": { "type": "string" @@ -5429,11 +5213,9 @@ var doc = `{ } } } - } - }, - "/scripts/{id}/invite/code/{code_id}": { - "delete": { - "description": "删除邀请码", + }, + "put": { + "description": "更新脚本/库代码", "consumes": [ "application/json" ], @@ -5441,9 +5223,9 @@ var doc = `{ "application/json" ], "tags": [ - "script/access_invite" + "script" ], - "summary": "删除邀请码", + "summary": "更新脚本/库代码", "parameters": [ { "type": "integer", @@ -5451,17 +5233,11 @@ var doc = `{ "in": "path", "required": true }, - { - "type": "integer", - "name": "code_id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.DeleteInviteCodeRequest" + "$ref": "#/definitions/script.UpdateCodeRequest" } } ], @@ -5474,7 +5250,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.DeleteInviteCodeResponse" + "$ref": "#/definitions/script.UpdateCodeResponse" }, "msg": { "type": "string" @@ -5491,9 +5267,9 @@ var doc = `{ } } }, - "/scripts/{id}/invite/code/{code_id}/audit": { + "/scripts/{id}/code/{codeId}": { "put": { - "description": "审核邀请码", + "description": "更新脚本设置", "consumes": [ "application/json" ], @@ -5501,9 +5277,9 @@ var doc = `{ "application/json" ], "tags": [ - "script/access_invite" + "script" ], - "summary": "审核邀请码", + "summary": "更新脚本设置", "parameters": [ { "type": "integer", @@ -5513,7 +5289,7 @@ var doc = `{ }, { "type": "integer", - "name": "code_id", + "name": "codeId", "in": "path", "required": true }, @@ -5521,7 +5297,7 @@ var doc = `{ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.AuditInviteCodeRequest" + "$ref": "#/definitions/script.UpdateCodeSettingRequest" } } ], @@ -5534,7 +5310,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.AuditInviteCodeResponse" + "$ref": "#/definitions/script.UpdateCodeSettingResponse" }, "msg": { "type": "string" @@ -5549,11 +5325,9 @@ var doc = `{ } } } - } - }, - "/scripts/{id}/invite/group/{gid}/code": { - "get": { - "description": "群组邀请码列表", + }, + "delete": { + "description": "删除脚本/库代码", "consumes": [ "application/json" ], @@ -5561,9 +5335,9 @@ var doc = `{ "application/json" ], "tags": [ - "script/access_invite" + "script" ], - "summary": "群组邀请码列表", + "summary": "删除脚本/库代码", "parameters": [ { "type": "integer", @@ -5573,9 +5347,16 @@ var doc = `{ }, { "type": "integer", - "name": "gid", + "name": "codeId", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.DeleteCodeRequest" + } } ], "responses": { @@ -5587,7 +5368,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.GroupInviteCodeListResponse" + "$ref": "#/definitions/script.DeleteCodeResponse" }, "msg": { "type": "string" @@ -5602,9 +5383,10 @@ var doc = `{ } } } - }, - "post": { - "description": "创建群组邀请码", + } + }, + "/scripts/{id}/commentReply": { + "put": { "consumes": [ "application/json" ], @@ -5612,9 +5394,8 @@ var doc = `{ "application/json" ], "tags": [ - "script/access_invite" + "script/score" ], - "summary": "创建群组邀请码", "parameters": [ { "type": "integer", @@ -5622,17 +5403,11 @@ var doc = `{ "in": "path", "required": true }, - { - "type": "integer", - "name": "gid", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.CreateGroupInviteCodeRequest" + "$ref": "#/definitions/script.ReplyScoreRequest" } } ], @@ -5645,7 +5420,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.CreateGroupInviteCodeResponse" + "$ref": "#/definitions/script.ReplyScoreResponse" }, "msg": { "type": "string" @@ -5662,9 +5437,9 @@ var doc = `{ } } }, - "/scripts/{id}/issues": { + "/scripts/{id}/group": { "get": { - "description": "获取脚本反馈列表", + "description": "群组列表", "consumes": [ "application/json" ], @@ -5672,9 +5447,9 @@ var doc = `{ "application/json" ], "tags": [ - "issue" + "script/group" ], - "summary": "获取脚本反馈列表", + "summary": "群组列表", "parameters": [ { "type": "integer", @@ -5684,13 +5459,7 @@ var doc = `{ }, { "type": "string", - "name": "keyword", - "in": "query" - }, - { - "type": "integer", - "description": "0:全部 1:待解决 3:已关闭", - "name": "status,default=0", + "name": "query", "in": "query" } ], @@ -5703,7 +5472,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.ListResponse" + "$ref": "#/definitions/script.GroupListResponse" }, "msg": { "type": "string" @@ -5720,7 +5489,7 @@ var doc = `{ } }, "post": { - "description": "创建脚本反馈", + "description": "创建群组", "consumes": [ "application/json" ], @@ -5728,9 +5497,9 @@ var doc = `{ "application/json" ], "tags": [ - "issue" + "script/group" ], - "summary": "创建脚本反馈", + "summary": "创建群组", "parameters": [ { "type": "integer", @@ -5742,7 +5511,7 @@ var doc = `{ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/issue.CreateIssueRequest" + "$ref": "#/definitions/script.CreateGroupRequest" } } ], @@ -5755,7 +5524,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.CreateIssueResponse" + "$ref": "#/definitions/script.CreateGroupResponse" }, "msg": { "type": "string" @@ -5772,9 +5541,9 @@ var doc = `{ } } }, - "/scripts/{id}/issues/{issueId}": { - "get": { - "description": "获取issue信息", + "/scripts/{id}/group/{gid}": { + "put": { + "description": "更新群组", "consumes": [ "application/json" ], @@ -5782,9 +5551,9 @@ var doc = `{ "application/json" ], "tags": [ - "issue" + "script/group" ], - "summary": "获取issue信息", + "summary": "更新群组", "parameters": [ { "type": "integer", @@ -5794,9 +5563,16 @@ var doc = `{ }, { "type": "integer", - "name": "issueId", + "name": "gid", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.UpdateGroupRequest" + } } ], "responses": { @@ -5808,7 +5584,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.GetIssueResponse" + "$ref": "#/definitions/script.UpdateGroupResponse" }, "msg": { "type": "string" @@ -5825,7 +5601,7 @@ var doc = `{ } }, "delete": { - "description": "删除issue", + "description": "删除群组", "consumes": [ "application/json" ], @@ -5833,9 +5609,9 @@ var doc = `{ "application/json" ], "tags": [ - "issue" + "script/group" ], - "summary": "删除issue", + "summary": "删除群组", "parameters": [ { "type": "integer", @@ -5845,7 +5621,7 @@ var doc = `{ }, { "type": "integer", - "name": "issueId", + "name": "gid", "in": "path", "required": true }, @@ -5853,7 +5629,7 @@ var doc = `{ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/issue.DeleteRequest" + "$ref": "#/definitions/script.DeleteGroupRequest" } } ], @@ -5866,7 +5642,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.DeleteResponse" + "$ref": "#/definitions/script.DeleteGroupResponse" }, "msg": { "type": "string" @@ -5883,9 +5659,9 @@ var doc = `{ } } }, - "/scripts/{id}/issues/{issueId}/comment": { + "/scripts/{id}/group/{gid}/member": { "get": { - "description": "获取反馈评论列表", + "description": "群组成员列表", "consumes": [ "application/json" ], @@ -5893,9 +5669,9 @@ var doc = `{ "application/json" ], "tags": [ - "issue/comment" + "script/group" ], - "summary": "获取反馈评论列表", + "summary": "群组成员列表", "parameters": [ { "type": "integer", @@ -5905,7 +5681,7 @@ var doc = `{ }, { "type": "integer", - "name": "issueId", + "name": "gid", "in": "path", "required": true } @@ -5919,7 +5695,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.ListCommentResponse" + "$ref": "#/definitions/script.GroupMemberListResponse" }, "msg": { "type": "string" @@ -5936,7 +5712,7 @@ var doc = `{ } }, "post": { - "description": "创建反馈评论", + "description": "添加成员", "consumes": [ "application/json" ], @@ -5944,9 +5720,9 @@ var doc = `{ "application/json" ], "tags": [ - "issue/comment" + "script/group" ], - "summary": "创建反馈评论", + "summary": "添加成员", "parameters": [ { "type": "integer", @@ -5956,7 +5732,7 @@ var doc = `{ }, { "type": "integer", - "name": "issueId", + "name": "gid", "in": "path", "required": true }, @@ -5964,7 +5740,7 @@ var doc = `{ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/issue.CreateCommentRequest" + "$ref": "#/definitions/script.AddMemberRequest" } } ], @@ -5977,7 +5753,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.CreateCommentResponse" + "$ref": "#/definitions/script.AddMemberResponse" }, "msg": { "type": "string" @@ -5994,9 +5770,9 @@ var doc = `{ } } }, - "/scripts/{id}/issues/{issueId}/comment/{commentId}": { - "delete": { - "description": "删除反馈评论", + "/scripts/{id}/group/{gid}/member/{mid}": { + "put": { + "description": "更新成员", "consumes": [ "application/json" ], @@ -6004,25 +5780,25 @@ var doc = `{ "application/json" ], "tags": [ - "issue/comment" + "script/group" ], - "summary": "删除反馈评论", + "summary": "更新成员", "parameters": [ { "type": "integer", - "name": "id", + "name": "mid", "in": "path", "required": true }, { "type": "integer", - "name": "issueId", + "name": "id", "in": "path", "required": true }, { "type": "integer", - "name": "commentId", + "name": "gid", "in": "path", "required": true }, @@ -6030,7 +5806,7 @@ var doc = `{ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/issue.DeleteCommentRequest" + "$ref": "#/definitions/script.UpdateMemberRequest" } } ], @@ -6043,7 +5819,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.DeleteCommentResponse" + "$ref": "#/definitions/script.UpdateMemberResponse" }, "msg": { "type": "string" @@ -6058,11 +5834,9 @@ var doc = `{ } } } - } - }, - "/scripts/{id}/issues/{issueId}/labels": { - "put": { - "description": "更新issue标签", + }, + "delete": { + "description": "移除成员", "consumes": [ "application/json" ], @@ -6070,9 +5844,9 @@ var doc = `{ "application/json" ], "tags": [ - "issue" + "script/group" ], - "summary": "更新issue标签", + "summary": "移除成员", "parameters": [ { "type": "integer", @@ -6082,7 +5856,13 @@ var doc = `{ }, { "type": "integer", - "name": "issueId", + "name": "gid", + "in": "path", + "required": true + }, + { + "type": "integer", + "name": "mid", "in": "path", "required": true }, @@ -6090,7 +5870,7 @@ var doc = `{ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/issue.UpdateLabelsRequest" + "$ref": "#/definitions/script.RemoveMemberRequest" } } ], @@ -6103,7 +5883,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.UpdateLabelsResponse" + "$ref": "#/definitions/script.RemoveMemberResponse" }, "msg": { "type": "string" @@ -6120,9 +5900,9 @@ var doc = `{ } } }, - "/scripts/{id}/issues/{issueId}/open": { - "put": { - "description": "打开/关闭issue", + "/scripts/{id}/invite/code": { + "get": { + "description": "邀请码列表", "consumes": [ "application/json" ], @@ -6130,28 +5910,15 @@ var doc = `{ "application/json" ], "tags": [ - "issue" + "script/access_invite" ], - "summary": "打开/关闭issue", + "summary": "邀请码列表", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "type": "integer", - "name": "issueId", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/issue.OpenRequest" - } } ], "responses": { @@ -6163,7 +5930,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.OpenResponse" + "$ref": "#/definitions/script.InviteCodeListResponse" }, "msg": { "type": "string" @@ -6178,11 +5945,9 @@ var doc = `{ } } } - } - }, - "/scripts/{id}/issues/{issueId}/watch": { - "get": { - "description": "获取issue关注状态", + }, + "post": { + "description": "创建邀请码", "consumes": [ "application/json" ], @@ -6190,9 +5955,9 @@ var doc = `{ "application/json" ], "tags": [ - "issue" + "script/access_invite" ], - "summary": "获取issue关注状态", + "summary": "创建邀请码", "parameters": [ { "type": "integer", @@ -6201,10 +5966,11 @@ var doc = `{ "required": true }, { - "type": "integer", - "name": "issueId", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.CreateInviteCodeRequest" + } } ], "responses": { @@ -6216,7 +5982,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.GetWatchResponse" + "$ref": "#/definitions/script.CreateInviteCodeResponse" }, "msg": { "type": "string" @@ -6231,9 +5997,11 @@ var doc = `{ } } } - }, - "put": { - "description": "关注issue", + } + }, + "/scripts/{id}/invite/code/{code_id}": { + "delete": { + "description": "删除邀请码", "consumes": [ "application/json" ], @@ -6241,9 +6009,9 @@ var doc = `{ "application/json" ], "tags": [ - "issue" + "script/access_invite" ], - "summary": "关注issue", + "summary": "删除邀请码", "parameters": [ { "type": "integer", @@ -6253,7 +6021,7 @@ var doc = `{ }, { "type": "integer", - "name": "issueId", + "name": "code_id", "in": "path", "required": true }, @@ -6261,7 +6029,7 @@ var doc = `{ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/issue.WatchRequest" + "$ref": "#/definitions/script.DeleteInviteCodeRequest" } } ], @@ -6274,7 +6042,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.WatchResponse" + "$ref": "#/definitions/script.DeleteInviteCodeResponse" }, "msg": { "type": "string" @@ -6291,9 +6059,9 @@ var doc = `{ } } }, - "/scripts/{id}/public": { + "/scripts/{id}/invite/code/{code_id}/audit": { "put": { - "description": "更新脚本公开类型", + "description": "审核邀请码", "consumes": [ "application/json" ], @@ -6301,9 +6069,9 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "script/access_invite" ], - "summary": "更新脚本公开类型", + "summary": "审核邀请码", "parameters": [ { "type": "integer", @@ -6311,11 +6079,17 @@ var doc = `{ "in": "path", "required": true }, + { + "type": "integer", + "name": "code_id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.UpdateScriptPublicRequest" + "$ref": "#/definitions/script.AuditInviteCodeRequest" } } ], @@ -6328,7 +6102,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateScriptPublicResponse" + "$ref": "#/definitions/script.AuditInviteCodeResponse" }, "msg": { "type": "string" @@ -6345,9 +6119,9 @@ var doc = `{ } } }, - "/scripts/{id}/reports": { + "/scripts/{id}/invite/group/{gid}/code": { "get": { - "description": "获取脚本举报列表", + "description": "群组邀请码列表", "consumes": [ "application/json" ], @@ -6355,9 +6129,9 @@ var doc = `{ "application/json" ], "tags": [ - "report" + "script/access_invite" ], - "summary": "获取脚本举报列表", + "summary": "群组邀请码列表", "parameters": [ { "type": "integer", @@ -6367,9 +6141,9 @@ var doc = `{ }, { "type": "integer", - "description": "0:全部 1:待处理 3:已解决", - "name": "status,default=0", - "in": "query" + "name": "gid", + "in": "path", + "required": true } ], "responses": { @@ -6381,7 +6155,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/report.ListResponse" + "$ref": "#/definitions/script.GroupInviteCodeListResponse" }, "msg": { "type": "string" @@ -6398,7 +6172,7 @@ var doc = `{ } }, "post": { - "description": "创建脚本举报", + "description": "创建群组邀请码", "consumes": [ "application/json" ], @@ -6406,9 +6180,9 @@ var doc = `{ "application/json" ], "tags": [ - "report" + "script/access_invite" ], - "summary": "创建脚本举报", + "summary": "创建群组邀请码", "parameters": [ { "type": "integer", @@ -6416,11 +6190,17 @@ var doc = `{ "in": "path", "required": true }, + { + "type": "integer", + "name": "gid", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/report.CreateReportRequest" + "$ref": "#/definitions/script.CreateGroupInviteCodeRequest" } } ], @@ -6433,7 +6213,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/report.CreateReportResponse" + "$ref": "#/definitions/script.CreateGroupInviteCodeResponse" }, "msg": { "type": "string" @@ -6450,9 +6230,9 @@ var doc = `{ } } }, - "/scripts/{id}/reports/{reportId}": { + "/scripts/{id}/issues": { "get": { - "description": "获取举报详情", + "description": "获取脚本反馈列表", "consumes": [ "application/json" ], @@ -6460,9 +6240,9 @@ var doc = `{ "application/json" ], "tags": [ - "report" + "issue" ], - "summary": "获取举报详情", + "summary": "获取脚本反馈列表", "parameters": [ { "type": "integer", @@ -6470,11 +6250,16 @@ var doc = `{ "in": "path", "required": true }, + { + "type": "string", + "name": "keyword", + "in": "query" + }, { "type": "integer", - "name": "reportId", - "in": "path", - "required": true + "description": "0:全部 1:待解决 3:已关闭", + "name": "status,default=0", + "in": "query" } ], "responses": { @@ -6486,7 +6271,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/report.GetReportResponse" + "$ref": "#/definitions/issue.ListResponse" }, "msg": { "type": "string" @@ -6502,8 +6287,8 @@ var doc = `{ } } }, - "delete": { - "description": "删除举报", + "post": { + "description": "创建脚本反馈", "consumes": [ "application/json" ], @@ -6511,9 +6296,9 @@ var doc = `{ "application/json" ], "tags": [ - "report" + "issue" ], - "summary": "删除举报", + "summary": "创建脚本反馈", "parameters": [ { "type": "integer", @@ -6521,17 +6306,11 @@ var doc = `{ "in": "path", "required": true }, - { - "type": "integer", - "name": "reportId", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/report.DeleteRequest" + "$ref": "#/definitions/issue.CreateIssueRequest" } } ], @@ -6544,7 +6323,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/report.DeleteResponse" + "$ref": "#/definitions/issue.CreateIssueResponse" }, "msg": { "type": "string" @@ -6561,9 +6340,9 @@ var doc = `{ } } }, - "/scripts/{id}/reports/{reportId}/comments": { + "/scripts/{id}/issues/{issueId}": { "get": { - "description": "获取举报评论列表", + "description": "获取issue信息", "consumes": [ "application/json" ], @@ -6571,9 +6350,9 @@ var doc = `{ "application/json" ], "tags": [ - "report/comment" + "issue" ], - "summary": "获取举报评论列表", + "summary": "获取issue信息", "parameters": [ { "type": "integer", @@ -6583,7 +6362,7 @@ var doc = `{ }, { "type": "integer", - "name": "reportId", + "name": "issueId", "in": "path", "required": true } @@ -6597,7 +6376,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/report.ListCommentResponse" + "$ref": "#/definitions/issue.GetIssueResponse" }, "msg": { "type": "string" @@ -6613,8 +6392,8 @@ var doc = `{ } } }, - "post": { - "description": "创建举报评论", + "delete": { + "description": "删除issue", "consumes": [ "application/json" ], @@ -6622,9 +6401,9 @@ var doc = `{ "application/json" ], "tags": [ - "report/comment" + "issue" ], - "summary": "创建举报评论", + "summary": "删除issue", "parameters": [ { "type": "integer", @@ -6634,7 +6413,7 @@ var doc = `{ }, { "type": "integer", - "name": "reportId", + "name": "issueId", "in": "path", "required": true }, @@ -6642,7 +6421,7 @@ var doc = `{ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/report.CreateCommentRequest" + "$ref": "#/definitions/issue.DeleteRequest" } } ], @@ -6655,7 +6434,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/report.CreateCommentResponse" + "$ref": "#/definitions/issue.DeleteResponse" }, "msg": { "type": "string" @@ -6672,9 +6451,9 @@ var doc = `{ } } }, - "/scripts/{id}/reports/{reportId}/comments/{commentId}": { - "delete": { - "description": "删除举报评论", + "/scripts/{id}/issues/{issueId}/comment": { + "get": { + "description": "获取反馈评论列表", "consumes": [ "application/json" ], @@ -6682,9 +6461,9 @@ var doc = `{ "application/json" ], "tags": [ - "report/comment" + "issue/comment" ], - "summary": "删除举报评论", + "summary": "获取反馈评论列表", "parameters": [ { "type": "integer", @@ -6694,22 +6473,9 @@ var doc = `{ }, { "type": "integer", - "name": "reportId", - "in": "path", - "required": true - }, - { - "type": "integer", - "name": "commentId", + "name": "issueId", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/report.DeleteCommentRequest" - } } ], "responses": { @@ -6721,7 +6487,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/report.DeleteCommentResponse" + "$ref": "#/definitions/issue.ListCommentResponse" }, "msg": { "type": "string" @@ -6736,11 +6502,9 @@ var doc = `{ } } } - } - }, - "/scripts/{id}/reports/{reportId}/resolve": { - "put": { - "description": "解决/重新打开举报", + }, + "post": { + "description": "创建反馈评论", "consumes": [ "application/json" ], @@ -6748,9 +6512,9 @@ var doc = `{ "application/json" ], "tags": [ - "report" + "issue/comment" ], - "summary": "解决/重新打开举报", + "summary": "创建反馈评论", "parameters": [ { "type": "integer", @@ -6760,7 +6524,7 @@ var doc = `{ }, { "type": "integer", - "name": "reportId", + "name": "issueId", "in": "path", "required": true }, @@ -6768,7 +6532,7 @@ var doc = `{ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/report.ResolveRequest" + "$ref": "#/definitions/issue.CreateCommentRequest" } } ], @@ -6781,7 +6545,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/report.ResolveResponse" + "$ref": "#/definitions/issue.CreateCommentResponse" }, "msg": { "type": "string" @@ -6798,9 +6562,9 @@ var doc = `{ } } }, - "/scripts/{id}/score": { - "get": { - "description": "获取脚本评分列表", + "/scripts/{id}/issues/{issueId}/comment/{commentId}": { + "delete": { + "description": "删除反馈评论", "consumes": [ "application/json" ], @@ -6808,15 +6572,34 @@ var doc = `{ "application/json" ], "tags": [ - "script/score" + "issue/comment" ], - "summary": "获取脚本评分列表", + "summary": "删除反馈评论", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true + }, + { + "type": "integer", + "name": "issueId", + "in": "path", + "required": true + }, + { + "type": "integer", + "name": "commentId", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/issue.DeleteCommentRequest" + } } ], "responses": { @@ -6828,7 +6611,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.ScoreListResponse" + "$ref": "#/definitions/issue.DeleteCommentResponse" }, "msg": { "type": "string" @@ -6843,9 +6626,11 @@ var doc = `{ } } } - }, + } + }, + "/scripts/{id}/issues/{issueId}/labels": { "put": { - "description": "脚本评分", + "description": "更新issue标签", "consumes": [ "application/json" ], @@ -6853,9 +6638,9 @@ var doc = `{ "application/json" ], "tags": [ - "script/score" + "issue" ], - "summary": "脚本评分", + "summary": "更新issue标签", "parameters": [ { "type": "integer", @@ -6863,11 +6648,17 @@ var doc = `{ "in": "path", "required": true }, + { + "type": "integer", + "name": "issueId", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.PutScoreRequest" + "$ref": "#/definitions/issue.UpdateLabelsRequest" } } ], @@ -6880,7 +6671,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.PutScoreResponse" + "$ref": "#/definitions/issue.UpdateLabelsResponse" }, "msg": { "type": "string" @@ -6897,9 +6688,9 @@ var doc = `{ } } }, - "/scripts/{id}/score/self": { - "get": { - "description": "用于获取自己对脚本的评价", + "/scripts/{id}/issues/{issueId}/open": { + "put": { + "description": "打开/关闭issue", "consumes": [ "application/json" ], @@ -6907,15 +6698,28 @@ var doc = `{ "application/json" ], "tags": [ - "script/score" + "issue" ], - "summary": "用于获取自己对脚本的评价", + "summary": "打开/关闭issue", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true + }, + { + "type": "integer", + "name": "issueId", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/issue.OpenRequest" + } } ], "responses": { @@ -6927,7 +6731,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.SelfScoreResponse" + "$ref": "#/definitions/issue.OpenResponse" }, "msg": { "type": "string" @@ -6944,9 +6748,9 @@ var doc = `{ } } }, - "/scripts/{id}/score/state": { + "/scripts/{id}/issues/{issueId}/watch": { "get": { - "description": "获取脚本评分状态", + "description": "获取issue关注状态", "consumes": [ "application/json" ], @@ -6954,15 +6758,21 @@ var doc = `{ "application/json" ], "tags": [ - "script/score" + "issue" ], - "summary": "获取脚本评分状态", + "summary": "获取issue关注状态", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true + }, + { + "type": "integer", + "name": "issueId", + "in": "path", + "required": true } ], "responses": { @@ -6974,7 +6784,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.ScoreStateResponse" + "$ref": "#/definitions/issue.GetWatchResponse" }, "msg": { "type": "string" @@ -6989,11 +6799,9 @@ var doc = `{ } } } - } - }, - "/scripts/{id}/score/{scoreId}": { - "delete": { - "description": "用于删除脚本的评价,注意,只有管理员才有权限删除评价", + }, + "put": { + "description": "关注issue", "consumes": [ "application/json" ], @@ -7001,9 +6809,9 @@ var doc = `{ "application/json" ], "tags": [ - "script/score" + "issue" ], - "summary": "用于删除脚本的评价,注意,只有管理员才有权限删除评价", + "summary": "关注issue", "parameters": [ { "type": "integer", @@ -7013,7 +6821,7 @@ var doc = `{ }, { "type": "integer", - "name": "scoreId", + "name": "issueId", "in": "path", "required": true }, @@ -7021,7 +6829,7 @@ var doc = `{ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.DelScoreRequest" + "$ref": "#/definitions/issue.WatchRequest" } } ], @@ -7034,7 +6842,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.DelScoreResponse" + "$ref": "#/definitions/issue.WatchResponse" }, "msg": { "type": "string" @@ -7051,9 +6859,9 @@ var doc = `{ } } }, - "/scripts/{id}/setting": { - "get": { - "description": "获取脚本设置", + "/scripts/{id}/public": { + "put": { + "description": "更新脚本公开类型", "consumes": [ "application/json" ], @@ -7063,13 +6871,20 @@ var doc = `{ "tags": [ "script" ], - "summary": "获取脚本设置", + "summary": "更新脚本公开类型", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.UpdateScriptPublicRequest" + } } ], "responses": { @@ -7081,7 +6896,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.GetSettingResponse" + "$ref": "#/definitions/script.UpdateScriptPublicResponse" }, "msg": { "type": "string" @@ -7096,9 +6911,11 @@ var doc = `{ } } } - }, - "put": { - "description": "更新脚本设置", + } + }, + "/scripts/{id}/reports": { + "get": { + "description": "获取脚本举报列表", "consumes": [ "application/json" ], @@ -7106,9 +6923,9 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "report" ], - "summary": "更新脚本设置", + "summary": "获取脚本举报列表", "parameters": [ { "type": "integer", @@ -7117,11 +6934,10 @@ var doc = `{ "required": true }, { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.UpdateSettingRequest" - } + "type": "integer", + "description": "0:全部 1:待处理 3:已解决", + "name": "status,default=0", + "in": "query" } ], "responses": { @@ -7133,7 +6949,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateSettingResponse" + "$ref": "#/definitions/report.ListResponse" }, "msg": { "type": "string" @@ -7148,11 +6964,9 @@ var doc = `{ } } } - } - }, - "/scripts/{id}/state": { - "get": { - "description": "获取脚本状态,脚本关注等", + }, + "post": { + "description": "创建脚本举报", "consumes": [ "application/json" ], @@ -7160,15 +6974,22 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "report" ], - "summary": "获取脚本状态,脚本关注等", + "summary": "创建脚本举报", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/report.CreateReportRequest" + } } ], "responses": { @@ -7180,7 +7001,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.StateResponse" + "$ref": "#/definitions/report.CreateReportResponse" }, "msg": { "type": "string" @@ -7197,9 +7018,9 @@ var doc = `{ } } }, - "/scripts/{id}/sync": { - "put": { - "description": "更新同步配置", + "/scripts/{id}/reports/{reportId}": { + "get": { + "description": "获取举报详情", "consumes": [ "application/json" ], @@ -7207,9 +7028,9 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "report" ], - "summary": "更新同步配置", + "summary": "获取举报详情", "parameters": [ { "type": "integer", @@ -7218,11 +7039,10 @@ var doc = `{ "required": true }, { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.UpdateSyncSettingRequest" - } + "type": "integer", + "name": "reportId", + "in": "path", + "required": true } ], "responses": { @@ -7234,7 +7054,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateSyncSettingResponse" + "$ref": "#/definitions/report.GetReportResponse" }, "msg": { "type": "string" @@ -7249,11 +7069,9 @@ var doc = `{ } } } - } - }, - "/scripts/{id}/unwell": { - "put": { - "description": "更新脚本不适内容", + }, + "delete": { + "description": "删除举报", "consumes": [ "application/json" ], @@ -7261,9 +7079,9 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "report" ], - "summary": "更新脚本不适内容", + "summary": "删除举报", "parameters": [ { "type": "integer", @@ -7271,11 +7089,17 @@ var doc = `{ "in": "path", "required": true }, + { + "type": "integer", + "name": "reportId", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.UpdateScriptUnwellRequest" + "$ref": "#/definitions/report.DeleteRequest" } } ], @@ -7288,7 +7112,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateScriptUnwellResponse" + "$ref": "#/definitions/report.DeleteResponse" }, "msg": { "type": "string" @@ -7305,9 +7129,9 @@ var doc = `{ } } }, - "/scripts/{id}/versions": { + "/scripts/{id}/reports/{reportId}/comments": { "get": { - "description": "获取版本列表", + "description": "获取举报评论列表", "consumes": [ "application/json" ], @@ -7315,15 +7139,21 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "report/comment" ], - "summary": "获取版本列表", + "summary": "获取举报评论列表", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true + }, + { + "type": "integer", + "name": "reportId", + "in": "path", + "required": true } ], "responses": { @@ -7335,7 +7165,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.VersionListResponse" + "$ref": "#/definitions/report.ListCommentResponse" }, "msg": { "type": "string" @@ -7350,11 +7180,9 @@ var doc = `{ } } } - } - }, - "/scripts/{id}/versions/stat": { - "get": { - "description": "获取脚本版本统计信息", + }, + "post": { + "description": "创建举报评论", "consumes": [ "application/json" ], @@ -7362,15 +7190,28 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "report/comment" ], - "summary": "获取脚本版本统计信息", + "summary": "创建举报评论", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true + }, + { + "type": "integer", + "name": "reportId", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/report.CreateCommentRequest" + } } ], "responses": { @@ -7382,7 +7223,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.VersionStatResponse" + "$ref": "#/definitions/report.CreateCommentResponse" }, "msg": { "type": "string" @@ -7399,9 +7240,9 @@ var doc = `{ } } }, - "/scripts/{id}/versions/{version}/code": { - "get": { - "description": "获取指定版本代码", + "/scripts/{id}/reports/{reportId}/comments/{commentId}": { + "delete": { + "description": "删除举报评论", "consumes": [ "application/json" ], @@ -7409,9 +7250,9 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "report/comment" ], - "summary": "获取指定版本代码", + "summary": "删除举报评论", "parameters": [ { "type": "integer", @@ -7420,10 +7261,23 @@ var doc = `{ "required": true }, { - "type": "string", - "name": "version", + "type": "integer", + "name": "reportId", + "in": "path", + "required": true + }, + { + "type": "integer", + "name": "commentId", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/report.DeleteCommentRequest" + } } ], "responses": { @@ -7435,7 +7289,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.VersionCodeResponse" + "$ref": "#/definitions/report.DeleteCommentResponse" }, "msg": { "type": "string" @@ -7452,9 +7306,9 @@ var doc = `{ } } }, - "/scripts/{id}/visit": { - "post": { - "description": "记录脚本访问统计", + "/scripts/{id}/reports/{reportId}/resolve": { + "put": { + "description": "解决/重新打开举报", "consumes": [ "application/json" ], @@ -7462,9 +7316,9 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "report" ], - "summary": "记录脚本访问统计", + "summary": "解决/重新打开举报", "parameters": [ { "type": "integer", @@ -7472,11 +7326,17 @@ var doc = `{ "in": "path", "required": true }, + { + "type": "integer", + "name": "reportId", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.RecordVisitRequest" + "$ref": "#/definitions/report.ResolveRequest" } } ], @@ -7489,7 +7349,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.RecordVisitResponse" + "$ref": "#/definitions/report.ResolveResponse" }, "msg": { "type": "string" @@ -7506,9 +7366,9 @@ var doc = `{ } } }, - "/scripts/{id}/watch": { - "post": { - "description": "关注脚本", + "/scripts/{id}/score": { + "get": { + "description": "获取脚本评分列表", "consumes": [ "application/json" ], @@ -7516,22 +7376,15 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "script/score" ], - "summary": "关注脚本", + "summary": "获取脚本评分列表", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.WatchRequest" - } } ], "responses": { @@ -7543,7 +7396,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.WatchResponse" + "$ref": "#/definitions/script.ScoreListResponse" }, "msg": { "type": "string" @@ -7558,11 +7411,9 @@ var doc = `{ } } } - } - }, - "/users": { - "get": { - "description": "获取当前登录的用户信息", + }, + "put": { + "description": "脚本评分", "consumes": [ "application/json" ], @@ -7570,9 +7421,24 @@ var doc = `{ "application/json" ], "tags": [ - "user" + "script/score" + ], + "summary": "脚本评分", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.PutScoreRequest" + } + } ], - "summary": "获取当前登录的用户信息", "responses": { "200": { "description": "OK", @@ -7582,7 +7448,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.CurrentUserResponse" + "$ref": "#/definitions/script.PutScoreResponse" }, "msg": { "type": "string" @@ -7599,9 +7465,9 @@ var doc = `{ } } }, - "/users/avatar": { - "put": { - "description": "更新用户头像", + "/scripts/{id}/score/self": { + "get": { + "description": "用于获取自己对脚本的评价", "consumes": [ "application/json" ], @@ -7609,16 +7475,15 @@ var doc = `{ "application/json" ], "tags": [ - "user" + "script/score" ], - "summary": "更新用户头像", + "summary": "用于获取自己对脚本的评价", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/user.UpdateUserAvatarRequest" - } + "type": "integer", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -7630,7 +7495,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.UpdateUserAvatarResponse" + "$ref": "#/definitions/script.SelfScoreResponse" }, "msg": { "type": "string" @@ -7647,9 +7512,9 @@ var doc = `{ } } }, - "/users/config": { + "/scripts/{id}/score/state": { "get": { - "description": "获取用户配置", + "description": "获取脚本评分状态", "consumes": [ "application/json" ], @@ -7657,9 +7522,17 @@ var doc = `{ "application/json" ], "tags": [ - "user" + "script/score" + ], + "summary": "获取脚本评分状态", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + } ], - "summary": "获取用户配置", "responses": { "200": { "description": "OK", @@ -7669,7 +7542,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.GetConfigResponse" + "$ref": "#/definitions/script.ScoreStateResponse" }, "msg": { "type": "string" @@ -7684,9 +7557,11 @@ var doc = `{ } } } - }, - "put": { - "description": "更新用户配置", + } + }, + "/scripts/{id}/score/{scoreId}": { + "delete": { + "description": "用于删除脚本的评价,注意,只有管理员才有权限删除评价", "consumes": [ "application/json" ], @@ -7694,15 +7569,27 @@ var doc = `{ "application/json" ], "tags": [ - "user" + "script/score" ], - "summary": "更新用户配置", + "summary": "用于删除脚本的评价,注意,只有管理员才有权限删除评价", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "name": "scoreId", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/user.UpdateConfigRequest" + "$ref": "#/definitions/script.DelScoreRequest" } } ], @@ -7715,7 +7602,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.UpdateConfigResponse" + "$ref": "#/definitions/script.DelScoreResponse" }, "msg": { "type": "string" @@ -7732,9 +7619,9 @@ var doc = `{ } } }, - "/users/deactivate": { - "post": { - "description": "确认注销", + "/scripts/{id}/setting": { + "get": { + "description": "获取脚本设置", "consumes": [ "application/json" ], @@ -7742,16 +7629,15 @@ var doc = `{ "application/json" ], "tags": [ - "user" + "script" ], - "summary": "确认注销", + "summary": "获取脚本设置", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/user.DeactivateRequest" - } + "type": "integer", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -7763,7 +7649,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.DeactivateResponse" + "$ref": "#/definitions/script.GetSettingResponse" }, "msg": { "type": "string" @@ -7779,8 +7665,8 @@ var doc = `{ } } }, - "delete": { - "description": "取消注销", + "put": { + "description": "更新脚本设置", "consumes": [ "application/json" ], @@ -7788,15 +7674,21 @@ var doc = `{ "application/json" ], "tags": [ - "user" + "script" ], - "summary": "取消注销", + "summary": "更新脚本设置", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/user.CancelDeactivateRequest" + "$ref": "#/definitions/script.UpdateSettingRequest" } } ], @@ -7809,7 +7701,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.CancelDeactivateResponse" + "$ref": "#/definitions/script.UpdateSettingResponse" }, "msg": { "type": "string" @@ -7826,9 +7718,9 @@ var doc = `{ } } }, - "/users/deactivate/code": { - "post": { - "description": "发送注销验证码", + "/scripts/{id}/state": { + "get": { + "description": "获取脚本状态,脚本关注等", "consumes": [ "application/json" ], @@ -7836,16 +7728,15 @@ var doc = `{ "application/json" ], "tags": [ - "user" + "script" ], - "summary": "发送注销验证码", + "summary": "获取脚本状态,脚本关注等", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/user.SendDeactivateCodeRequest" - } + "type": "integer", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -7857,7 +7748,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.SendDeactivateCodeResponse" + "$ref": "#/definitions/script.StateResponse" }, "msg": { "type": "string" @@ -7874,9 +7765,9 @@ var doc = `{ } } }, - "/users/detail": { + "/scripts/{id}/sync": { "put": { - "description": "更新用户信息", + "description": "更新同步配置", "consumes": [ "application/json" ], @@ -7884,15 +7775,21 @@ var doc = `{ "application/json" ], "tags": [ - "user" + "script" ], - "summary": "更新用户信息", + "summary": "更新同步配置", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/user.UpdateUserDetailRequest" + "$ref": "#/definitions/script.UpdateSyncSettingRequest" } } ], @@ -7905,7 +7802,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.UpdateUserDetailResponse" + "$ref": "#/definitions/script.UpdateSyncSettingResponse" }, "msg": { "type": "string" @@ -7922,8 +7819,9 @@ var doc = `{ } } }, - "/users/logout": { - "get": { + "/scripts/{id}/unwell": { + "put": { + "description": "更新脚本不适内容", "consumes": [ "application/json" ], @@ -7931,7 +7829,23 @@ var doc = `{ "application/json" ], "tags": [ - "user" + "script" + ], + "summary": "更新脚本不适内容", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.UpdateScriptUnwellRequest" + } + } ], "responses": { "200": { @@ -7942,7 +7856,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.LogoutResponse" + "$ref": "#/definitions/script.UpdateScriptUnwellResponse" }, "msg": { "type": "string" @@ -7959,9 +7873,9 @@ var doc = `{ } } }, - "/users/oauth/bind/{id}": { - "delete": { - "description": "DELETE /users/oauth/bind/:id — 解绑 OAuth", + "/scripts/{id}/versions": { + "get": { + "description": "获取版本列表", "consumes": [ "application/json" ], @@ -7969,22 +7883,15 @@ var doc = `{ "application/json" ], "tags": [ - "auth/oidc_login" + "script" ], - "summary": "DELETE /users/oauth/bind/:id — 解绑 OAuth", + "summary": "获取版本列表", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/auth.UserOAuthUnbindRequest" - } } ], "responses": { @@ -7996,7 +7903,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.UserOAuthUnbindResponse" + "$ref": "#/definitions/script.VersionListResponse" }, "msg": { "type": "string" @@ -8013,9 +7920,9 @@ var doc = `{ } } }, - "/users/oauth/bindlist": { + "/scripts/{id}/versions/stat": { "get": { - "description": "GET /users/oauth/bindlist — 获取当前用户的 OAuth 绑定列表", + "description": "获取脚本版本统计信息", "consumes": [ "application/json" ], @@ -8023,9 +7930,17 @@ var doc = `{ "application/json" ], "tags": [ - "auth/oidc_login" + "script" + ], + "summary": "获取脚本版本统计信息", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + } ], - "summary": "GET /users/oauth/bindlist — 获取当前用户的 OAuth 绑定列表", "responses": { "200": { "description": "OK", @@ -8035,7 +7950,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.UserOAuthListResponse" + "$ref": "#/definitions/script.VersionStatResponse" }, "msg": { "type": "string" @@ -8052,9 +7967,9 @@ var doc = `{ } } }, - "/users/password": { - "put": { - "description": "修改密码", + "/scripts/{id}/versions/{version}/code": { + "get": { + "description": "获取指定版本代码", "consumes": [ "application/json" ], @@ -8062,15 +7977,74 @@ var doc = `{ "application/json" ], "tags": [ - "user" + "script" ], - "summary": "修改密码", + "summary": "获取指定版本代码", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "version", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/script.VersionCodeResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/scripts/{id}/visit": { + "post": { + "description": "记录脚本访问统计", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "script" + ], + "summary": "记录脚本访问统计", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/user.ChangePasswordRequest" + "$ref": "#/definitions/script.RecordVisitRequest" } } ], @@ -8083,7 +8057,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.ChangePasswordResponse" + "$ref": "#/definitions/script.RecordVisitResponse" }, "msg": { "type": "string" @@ -8100,9 +8074,9 @@ var doc = `{ } } }, - "/users/refresh-token": { + "/scripts/{id}/watch": { "post": { - "description": "刷新用户token", + "description": "关注脚本", "consumes": [ "application/json" ], @@ -8110,15 +8084,21 @@ var doc = `{ "application/json" ], "tags": [ - "user" + "script" ], - "summary": "刷新用户token", + "summary": "关注脚本", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/user.RefreshTokenRequest" + "$ref": "#/definitions/script.WatchRequest" } } ], @@ -8131,7 +8111,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.RefreshTokenResponse" + "$ref": "#/definitions/script.WatchResponse" }, "msg": { "type": "string" @@ -8148,9 +8128,8 @@ var doc = `{ } } }, - "/users/search": { + "/similarity/pair/{id}": { "get": { - "description": "搜索用户", "consumes": [ "application/json" ], @@ -8158,14 +8137,14 @@ var doc = `{ "application/json" ], "tags": [ - "user" + "similarity" ], - "summary": "搜索用户", "parameters": [ { - "type": "string", - "name": "query", - "in": "query" + "type": "integer", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -8177,7 +8156,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.SearchResponse" + "$ref": "#/definitions/similarity.GetEvidencePairResponse" }, "msg": { "type": "string" @@ -8194,9 +8173,9 @@ var doc = `{ } } }, - "/users/webhook": { + "/users": { "get": { - "description": "获取webhook配置", + "description": "获取当前登录的用户信息", "consumes": [ "application/json" ], @@ -8206,7 +8185,7 @@ var doc = `{ "tags": [ "user" ], - "summary": "获取webhook配置", + "summary": "获取当前登录的用户信息", "responses": { "200": { "description": "OK", @@ -8216,7 +8195,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.GetWebhookResponse" + "$ref": "#/definitions/user.CurrentUserResponse" }, "msg": { "type": "string" @@ -8231,9 +8210,11 @@ var doc = `{ } } } - }, + } + }, + "/users/avatar": { "put": { - "description": "刷新webhook配置", + "description": "更新用户头像", "consumes": [ "application/json" ], @@ -8243,13 +8224,13 @@ var doc = `{ "tags": [ "user" ], - "summary": "刷新webhook配置", + "summary": "更新用户头像", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/user.RefreshWebhookRequest" + "$ref": "#/definitions/user.UpdateUserAvatarRequest" } } ], @@ -8262,7 +8243,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.RefreshWebhookResponse" + "$ref": "#/definitions/user.UpdateUserAvatarResponse" }, "msg": { "type": "string" @@ -8279,9 +8260,9 @@ var doc = `{ } } }, - "/users/{user_id}/detail": { + "/users/config": { "get": { - "description": "获取用户详细信息", + "description": "获取用户配置", "consumes": [ "application/json" ], @@ -8291,15 +8272,7 @@ var doc = `{ "tags": [ "user" ], - "summary": "获取用户详细信息", - "parameters": [ - { - "type": "integer", - "name": "user_id", - "in": "path", - "required": true - } - ], + "summary": "获取用户配置", "responses": { "200": { "description": "OK", @@ -8309,7 +8282,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.GetUserDetailResponse" + "$ref": "#/definitions/user.GetConfigResponse" }, "msg": { "type": "string" @@ -8324,11 +8297,9 @@ var doc = `{ } } } - } - }, - "/users/{user_id}/follow": { - "get": { - "description": "获取用户关注信息", + }, + "put": { + "description": "更新用户配置", "consumes": [ "application/json" ], @@ -8338,13 +8309,14 @@ var doc = `{ "tags": [ "user" ], - "summary": "获取用户关注信息", + "summary": "更新用户配置", "parameters": [ { - "type": "integer", - "name": "user_id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.UpdateConfigRequest" + } } ], "responses": { @@ -8356,7 +8328,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.GetFollowResponse" + "$ref": "#/definitions/user.UpdateConfigResponse" }, "msg": { "type": "string" @@ -8371,9 +8343,11 @@ var doc = `{ } } } - }, + } + }, + "/users/deactivate": { "post": { - "description": "关注用户", + "description": "确认注销", "consumes": [ "application/json" ], @@ -8383,19 +8357,13 @@ var doc = `{ "tags": [ "user" ], - "summary": "关注用户", + "summary": "确认注销", "parameters": [ - { - "type": "integer", - "name": "user_id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/user.FollowRequest" + "$ref": "#/definitions/user.DeactivateRequest" } } ], @@ -8408,7 +8376,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.FollowResponse" + "$ref": "#/definitions/user.DeactivateResponse" }, "msg": { "type": "string" @@ -8423,11 +8391,9 @@ var doc = `{ } } } - } - }, - "/users/{user_id}/info": { - "get": { - "description": "获取指定用户信息", + }, + "delete": { + "description": "取消注销", "consumes": [ "application/json" ], @@ -8437,13 +8403,14 @@ var doc = `{ "tags": [ "user" ], - "summary": "获取指定用户信息", + "summary": "取消注销", "parameters": [ { - "type": "integer", - "name": "user_id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.CancelDeactivateRequest" + } } ], "responses": { @@ -8455,7 +8422,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.InfoResponse" + "$ref": "#/definitions/user.CancelDeactivateResponse" }, "msg": { "type": "string" @@ -8472,9 +8439,9 @@ var doc = `{ } } }, - "/users/{user_id}/scripts": { - "get": { - "description": "用户脚本列表", + "/users/deactivate/code": { + "post": { + "description": "发送注销验证码", "consumes": [ "application/json" ], @@ -8484,29 +8451,14 @@ var doc = `{ "tags": [ "user" ], - "summary": "用户脚本列表", + "summary": "发送注销验证码", "parameters": [ { - "type": "integer", - "name": "user_id", - "in": "path", - "required": true - }, - { - "type": "string", - "name": "keyword", - "in": "query" - }, - { - "type": "integer", - "description": "0:全部 1: 脚本 2: 库 3: 后台脚本 4: 定时脚本", - "name": "script_type,default=0", - "in": "query" - }, - { - "type": "string", - "name": "sort,default=today_download", - "in": "query" + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.SendDeactivateCodeRequest" + } } ], "responses": { @@ -8518,7 +8470,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/user.ScriptResponse" + "$ref": "#/definitions/user.SendDeactivateCodeResponse" }, "msg": { "type": "string" @@ -8535,9 +8487,9 @@ var doc = `{ } } }, - "/webhook/{user_id}": { - "post": { - "description": "处理webhook请求", + "/users/detail": { + "put": { + "description": "更新用户信息", "consumes": [ "application/json" ], @@ -8545,21 +8497,15 @@ var doc = `{ "application/json" ], "tags": [ - "script" + "user" ], - "summary": "处理webhook请求", + "summary": "更新用户信息", "parameters": [ - { - "type": "integer", - "name": "user_id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.WebhookRequest" + "$ref": "#/definitions/user.UpdateUserDetailRequest" } } ], @@ -8572,7 +8518,7 @@ var doc = `{ "type": "integer" }, "data": { - "$ref": "#/definitions/script.WebhookResponse" + "$ref": "#/definitions/user.UpdateUserDetailResponse" }, "msg": { "type": "string" @@ -8588,56 +8534,1203 @@ var doc = `{ } } } - } - }, - "definitions": { - "BadRequest": { - "type": "object", - "properties": { - "code": { - "description": "错误码", - "type": "integer", - "format": "int32" - }, - "msg": { + }, + "/users/email/code": { + "post": { + "description": "发送邮箱验证码", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "发送邮箱验证码", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.SendEmailCodeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.SendEmailCodeResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/logout": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.LogoutResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/oauth/bind/{id}": { + "delete": { + "description": "DELETE /users/oauth/bind/:id — 解绑 OAuth", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth/oidc_login" + ], + "summary": "DELETE /users/oauth/bind/:id — 解绑 OAuth", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/auth.UserOAuthUnbindRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/auth.UserOAuthUnbindResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/oauth/bindlist": { + "get": { + "description": "GET /users/oauth/bindlist — 获取当前用户的 OAuth 绑定列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth/oidc_login" + ], + "summary": "GET /users/oauth/bindlist — 获取当前用户的 OAuth 绑定列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/auth.UserOAuthListResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/password": { + "put": { + "description": "修改密码", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "修改密码", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.ChangePasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.ChangePasswordResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/refresh-token": { + "post": { + "description": "刷新用户token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "刷新用户token", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.RefreshTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.RefreshTokenResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/search": { + "get": { + "description": "搜索用户", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "搜索用户", + "parameters": [ + { + "type": "string", + "name": "query", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.SearchResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/webhook": { + "get": { + "description": "获取webhook配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取webhook配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.GetWebhookResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + }, + "put": { + "description": "刷新webhook配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "刷新webhook配置", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.RefreshWebhookRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.RefreshWebhookResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/{user_id}/detail": { + "get": { + "description": "获取用户详细信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取用户详细信息", + "parameters": [ + { + "type": "integer", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.GetUserDetailResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/{user_id}/follow": { + "get": { + "description": "获取用户关注信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取用户关注信息", + "parameters": [ + { + "type": "integer", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.GetFollowResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + }, + "post": { + "description": "关注用户", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "关注用户", + "parameters": [ + { + "type": "integer", + "name": "user_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.FollowRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.FollowResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/{user_id}/info": { + "get": { + "description": "获取指定用户信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取指定用户信息", + "parameters": [ + { + "type": "integer", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.InfoResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/{user_id}/scripts": { + "get": { + "description": "用户脚本列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户脚本列表", + "parameters": [ + { + "type": "integer", + "name": "user_id", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "0:全部 1: 脚本 2: 库 3: 后台脚本 4: 定时脚本", + "name": "script_type,default=0", + "in": "query" + }, + { + "type": "string", + "name": "sort,default=today_download", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.ScriptResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/webhook/{user_id}": { + "post": { + "description": "处理webhook请求", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "script" + ], + "summary": "处理webhook请求", + "parameters": [ + { + "type": "integer", + "name": "user_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.WebhookRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/script.WebhookResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + } + }, + "definitions": { + "BadRequest": { + "type": "object", + "properties": { + "code": { + "description": "错误码", + "type": "integer", + "format": "int32" + }, + "msg": { "description": "错误信息", "type": "string" } } }, - "admin.AdminDeleteFeedbackRequest": { + "admin.AdminDeleteFeedbackRequest": { + "type": "object" + }, + "admin.AdminDeleteFeedbackResponse": { + "type": "object" + }, + "admin.AdminReportItem": { + "type": "object", + "properties": { + "comment_count": { + "type": "integer" + }, + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "reason": { + "type": "string" + }, + "script_id": { + "type": "integer" + }, + "script_name": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "updatetime": { + "type": "integer" + }, + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "admin.AdminRestoreScriptRequest": { + "type": "object" + }, + "admin.AdminRestoreScriptResponse": { + "type": "object" + }, + "admin.AdminUpdateScriptVisibilityRequest": { + "type": "object", + "properties": { + "public": { + "type": "integer" + }, + "unwell": { + "type": "integer" + } + } + }, + "admin.AdminUpdateScriptVisibilityResponse": { + "type": "object" + }, + "admin.CreateOAuthAppRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "redirect_uri": { + "type": "string" + } + } + }, + "admin.CreateOAuthAppResponse": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "description": "#nosec G117 -- API 响应字段,非硬编码密码", + "type": "string" + }, + "id": { + "type": "integer" + } + } + }, + "admin.CreateOIDCProviderRequest": { + "type": "object", + "properties": { + "auth_url": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "client_secret": { + "description": "#nosec G117 -- API 请求字段,非硬编码密码", + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "icon": { + "type": "string" + }, + "issuer_url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "token_url": { + "type": "string" + }, + "type": { + "type": "string" + }, + "userinfo_url": { + "type": "string" + } + } + }, + "admin.CreateOIDCProviderResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, + "admin.DeleteOAuthAppRequest": { + "type": "object" + }, + "admin.DeleteOAuthAppResponse": { + "type": "object" + }, + "admin.DeleteOIDCProviderRequest": { + "type": "object" + }, + "admin.DeleteOIDCProviderResponse": { + "type": "object" + }, + "admin.DiscoverOIDCConfigRequest": { + "type": "object", + "properties": { + "issuer_url": { + "type": "string" + } + } + }, + "admin.DiscoverOIDCConfigResponse": { + "type": "object", + "properties": { + "authorization_endpoint": { + "type": "string" + }, + "issuer": { + "type": "string" + }, + "jwks_uri": { + "type": "string" + }, + "scopes_supported": { + "description": "逗号分隔", + "type": "string" + }, + "token_endpoint": { + "type": "string" + }, + "userinfo_endpoint": { + "type": "string" + } + } + }, + "admin.FeedbackItem": { + "type": "object", + "properties": { + "client_ip": { + "type": "string" + }, + "content": { + "type": "string" + }, + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "reason": { + "type": "string" + } + } + }, + "admin.GetMigrateAvatarStatusResponse": { + "type": "object", + "properties": { + "failed": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "migrated": { + "type": "integer" + }, + "running": { + "type": "boolean" + }, + "skipped": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "admin.GetSystemConfigsResponse": { + "type": "object", + "properties": { + "configs": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.SystemConfigItem" + } + } + } + }, + "admin.ListFeedbackResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/admin.FeedbackItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "admin.ListOAuthAppsResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/admin.OAuthAppItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "admin.ListOIDCProvidersResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/admin.OIDCProviderItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "admin.ListReportsResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/admin.AdminReportItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "admin.ListScoresResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/admin.ScoreItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "admin.ListScriptsResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/admin.ScriptItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "admin.ListUsersResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/admin.UserItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "admin.MigrateAvatarRequest": { "type": "object" }, - "admin.AdminDeleteFeedbackResponse": { + "admin.MigrateAvatarResponse": { "type": "object" }, - "admin.AdminReportItem": { + "admin.OAuthAppItem": { "type": "object", "properties": { - "comment_count": { - "type": "integer" + "client_id": { + "type": "string" }, "createtime": { "type": "integer" }, + "description": { + "type": "string" + }, "id": { "type": "integer" }, - "reason": { + "name": { "type": "string" }, - "script_id": { + "redirect_uri": { + "type": "string" + }, + "status": { "type": "integer" }, - "script_name": { + "updatetime": { + "type": "integer" + } + } + }, + "admin.OIDCProviderItem": { + "type": "object", + "properties": { + "auth_url": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "client_secret": { + "description": "#nosec G117 -- API 响应字段,掩码处理后返回", + "type": "string" + }, + "createtime": { + "type": "integer" + }, + "display_order": { + "type": "integer" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "issuer_url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "scopes": { "type": "string" }, "status": { "type": "integer" }, + "token_url": { + "type": "string" + }, + "type": { + "type": "string" + }, "updatetime": { "type": "integer" }, + "userinfo_url": { + "type": "string" + } + } + }, + "admin.ResetOAuthAppSecretRequest": { + "type": "object" + }, + "admin.ResetOAuthAppSecretResponse": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "description": "#nosec G117 -- API 响应字段,非硬编码密码", + "type": "string" + }, + "id": { + "type": "integer" + } + } + }, + "admin.ScoreItem": { + "type": "object", + "properties": { + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "score": { + "type": "integer" + }, + "script_id": { + "type": "integer" + }, + "script_name": { + "type": "string" + }, "user_id": { "type": "integer" }, @@ -8646,56 +9739,73 @@ var doc = `{ } } }, - "admin.AdminRestoreScriptRequest": { - "type": "object" - }, - "admin.AdminRestoreScriptResponse": { - "type": "object" - }, - "admin.AdminUpdateScriptVisibilityRequest": { + "admin.ScriptItem": { "type": "object", "properties": { + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, "public": { "type": "integer" }, + "status": { + "type": "integer" + }, + "type": { + "type": "integer" + }, "unwell": { "type": "integer" + }, + "updatetime": { + "type": "integer" + }, + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" } } }, - "admin.AdminUpdateScriptVisibilityResponse": { - "type": "object" - }, - "admin.CreateOAuthAppRequest": { + "admin.SystemConfigItem": { "type": "object", "properties": { - "description": { - "type": "string" - }, - "name": { + "key": { "type": "string" }, - "redirect_uri": { + "value": { "type": "string" } } }, - "admin.CreateOAuthAppResponse": { + "admin.UpdateOAuthAppRequest": { "type": "object", "properties": { - "client_id": { + "description": { "type": "string" }, - "client_secret": { - "description": "#nosec G117 -- API 响应字段,非硬编码密码", + "name": { "type": "string" }, - "id": { + "redirect_uri": { + "type": "string" + }, + "status": { "type": "integer" } } }, - "admin.CreateOIDCProviderRequest": { + "admin.UpdateOAuthAppResponse": { + "type": "object" + }, + "admin.UpdateOIDCProviderRequest": { "type": "object", "properties": { "auth_url": { @@ -8723,6 +9833,9 @@ var doc = `{ "scopes": { "type": "string" }, + "status": { + "type": "integer" + }, "token_url": { "type": "string" }, @@ -8734,65 +9847,98 @@ var doc = `{ } } }, - "admin.CreateOIDCProviderResponse": { + "admin.UpdateOIDCProviderResponse": { + "type": "object" + }, + "admin.UpdateSystemConfigsRequest": { "type": "object", "properties": { - "id": { - "type": "integer" + "configs": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.SystemConfigItem" + } } } }, - "admin.DeleteOAuthAppRequest": { - "type": "object" - }, - "admin.DeleteOAuthAppResponse": { + "admin.UpdateSystemConfigsResponse": { "type": "object" }, - "admin.DeleteOIDCProviderRequest": { - "type": "object" + "admin.UpdateUserAdminLevelRequest": { + "type": "object", + "properties": { + "admin_level": { + "type": "integer" + } + } }, - "admin.DeleteOIDCProviderResponse": { + "admin.UpdateUserAdminLevelResponse": { "type": "object" }, - "admin.DiscoverOIDCConfigRequest": { + "admin.UpdateUserStatusRequest": { "type": "object", "properties": { - "issuer_url": { + "clean_scores": { + "description": "是否清理用户评分", + "type": "boolean" + }, + "clean_scripts": { + "description": "是否清理用户脚本", + "type": "boolean" + }, + "expire_at": { + "description": "封禁到期时间(unix timestamp), 0=永久", + "type": "integer" + }, + "reason": { + "description": "封禁理由", "type": "string" + }, + "status": { + "type": "integer" } } }, - "admin.DiscoverOIDCConfigResponse": { + "admin.UpdateUserStatusResponse": { + "type": "object" + }, + "admin.UserItem": { "type": "object", "properties": { - "authorization_endpoint": { - "type": "string" + "admin_level": { + "type": "integer" }, - "issuer": { + "avatar": { "type": "string" }, - "jwks_uri": { + "createtime": { + "type": "integer" + }, + "email": { "type": "string" }, - "scopes_supported": { - "description": "逗号分隔", + "id": { + "type": "integer" + }, + "ip_location": { "type": "string" }, - "token_endpoint": { + "register_ip": { "type": "string" }, - "userinfo_endpoint": { + "status": { + "type": "integer" + }, + "username": { "type": "string" } } }, - "admin.FeedbackItem": { + "announcement.AdminAnnouncement": { "type": "object", "properties": { - "client_ip": { - "type": "string" - }, "content": { + "description": "JSON string", "type": "string" }, "createtime": { @@ -8801,53 +9947,57 @@ var doc = `{ "id": { "type": "integer" }, - "reason": { + "level": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "title": { + "description": "JSON string", "type": "string" + }, + "updatetime": { + "type": "integer" } } }, - "admin.GetMigrateAvatarStatusResponse": { + "announcement.AdminCreateRequest": { "type": "object", "properties": { - "failed": { - "type": "integer" - }, - "message": { + "content": { "type": "string" }, - "migrated": { - "type": "integer" - }, - "running": { - "type": "boolean" - }, - "skipped": { + "level": { "type": "integer" }, - "total": { - "type": "integer" + "title": { + "type": "string" } } }, - "admin.GetSystemConfigsResponse": { + "announcement.AdminCreateResponse": { "type": "object", "properties": { - "configs": { - "type": "array", - "items": { - "$ref": "#/definitions/admin.SystemConfigItem" - } + "id": { + "type": "integer" } } }, - "admin.ListFeedbackResponse": { + "announcement.AdminDeleteRequest": { + "type": "object" + }, + "announcement.AdminDeleteResponse": { + "type": "object" + }, + "announcement.AdminListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/admin.FeedbackItem" + "$ref": "#/definitions/announcement.AdminAnnouncement" } }, "total": { @@ -8855,29 +10005,57 @@ var doc = `{ } } }, - "admin.ListOAuthAppsResponse": { + "announcement.AdminUpdateRequest": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/admin.OAuthAppItem" - } + "content": { + "type": "string" }, - "total": { + "level": { + "type": "integer" + }, + "status": { "type": "integer" + }, + "title": { + "type": "string" } } }, - "admin.ListOIDCProvidersResponse": { + "announcement.AdminUpdateResponse": { + "type": "object" + }, + "announcement.Announcement": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "level": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, + "announcement.LatestResponse": { + "type": "object" + }, + "announcement.ListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/admin.OIDCProviderItem" + "$ref": "#/definitions/announcement.Announcement" } }, "total": { @@ -8885,14 +10063,49 @@ var doc = `{ } } }, - "admin.ListReportsResponse": { + "audit.AuditLogItem": { + "type": "object", + "properties": { + "action": { + "$ref": "#/definitions/audit_entity.Action" + }, + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "target_id": { + "type": "integer" + }, + "target_name": { + "type": "string" + }, + "target_type": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "audit.ListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/admin.AdminReportItem" + "$ref": "#/definitions/audit.AuditLogItem" } }, "total": { @@ -8900,14 +10113,14 @@ var doc = `{ } } }, - "admin.ListScoresResponse": { + "audit.ScriptListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/admin.ScoreItem" + "$ref": "#/definitions/audit.AuditLogItem" } }, "total": { @@ -8915,194 +10128,200 @@ var doc = `{ } } }, - "admin.ListScriptsResponse": { + "audit_entity.Action": { + "description": "Action enum type:\n- ActionScriptCreate: script_create\n- ActionScriptUpdate: script_update\n- ActionScriptDelete: script_delete", + "type": "string", + "enum": [ + "script_create", + "script_update", + "script_delete" + ] + }, + "auth.ForgotPasswordRequest": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/admin.ScriptItem" - } + "email": { + "type": "string" }, - "total": { - "type": "integer" + "turnstile_token": { + "type": "string" } } }, - "admin.ListUsersResponse": { + "auth.ForgotPasswordResponse": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/admin.UserItem" - } - }, - "total": { - "type": "integer" + "message": { + "type": "string" } } }, - "admin.MigrateAvatarRequest": { - "type": "object" - }, - "admin.MigrateAvatarResponse": { - "type": "object" - }, - "admin.OAuthAppItem": { + "auth.JWK": { "type": "object", "properties": { - "client_id": { + "alg": { "type": "string" }, - "createtime": { - "type": "integer" - }, - "description": { + "e": { "type": "string" }, - "id": { - "type": "integer" - }, - "name": { + "kid": { "type": "string" }, - "redirect_uri": { + "kty": { "type": "string" }, - "status": { - "type": "integer" + "n": { + "type": "string" }, - "updatetime": { - "type": "integer" + "use": { + "type": "string" } } }, - "admin.OIDCProviderItem": { + "auth.LoginRequest": { "type": "object", "properties": { - "auth_url": { + "account": { "type": "string" }, - "client_id": { + "password": { + "description": "#nosec G117 -- 这是字段定义", "type": "string" }, - "client_secret": { - "description": "#nosec G117 -- API 响应字段,掩码处理后返回", + "turnstile_token": { "type": "string" + } + } + }, + "auth.LoginResponse": { + "type": "object", + "properties": { + "require_webauthn,omitempty": { + "type": "boolean" }, - "createtime": { - "type": "integer" - }, - "display_order": { - "type": "integer" + "session_token,omitempty": { + "type": "string" + } + } + }, + "auth.OAuth2ApproveRequest": { + "type": "object", + "properties": { + "client_id": { + "type": "string" }, - "icon": { + "nonce": { "type": "string" }, - "id": { - "type": "integer" + "redirect_uri": { + "type": "string" }, - "issuer_url": { + "scope": { "type": "string" }, - "name": { + "state": { + "type": "string" + } + } + }, + "auth.OAuth2ApproveResponse": { + "type": "object", + "properties": { + "redirect_uri": { + "type": "string" + } + } + }, + "auth.OAuth2AuthorizeResponse": { + "type": "object", + "properties": { + "app_name": { "type": "string" }, - "scopes": { + "client_id": { "type": "string" }, - "status": { - "type": "integer" + "description": { + "type": "string" }, - "token_url": { + "nonce": { "type": "string" }, - "type": { + "redirect_uri": { "type": "string" }, - "updatetime": { - "type": "integer" + "scope": { + "type": "string" }, - "userinfo_url": { + "state": { "type": "string" } } }, - "admin.ResetOAuthAppSecretRequest": { - "type": "object" - }, - "admin.ResetOAuthAppSecretResponse": { + "auth.OAuth2TokenRequest": { "type": "object", "properties": { "client_id": { "type": "string" }, "client_secret": { - "description": "#nosec G117 -- API 响应字段,非硬编码密码", + "description": "#nosec G117 -- API 请求字段,非硬编码密码", "type": "string" }, - "id": { - "type": "integer" + "code": { + "type": "string" + }, + "grant_type": { + "type": "string" + }, + "redirect_uri": { + "type": "string" } } }, - "admin.ScoreItem": { + "auth.OAuth2TokenResponse": { "type": "object", "properties": { - "createtime": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "message": { + "access_token": { + "description": "#nosec G117 -- API 响应字段,非硬编码密码", "type": "string" }, - "score": { - "type": "integer" - }, - "script_id": { + "expires_in": { "type": "integer" }, - "script_name": { + "id_token,omitempty": { "type": "string" }, - "user_id": { - "type": "integer" + "scope,omitempty": { + "type": "string" }, - "username": { + "token_type": { "type": "string" } } }, - "admin.ScriptItem": { + "auth.OAuth2UserInfoResponse": { "type": "object", "properties": { - "createtime": { - "type": "integer" + "avatar": { + "type": "string" }, - "id": { - "type": "integer" + "email": { + "type": "string" }, "name": { "type": "string" }, - "public": { - "type": "integer" - }, - "status": { - "type": "integer" - }, - "type": { - "type": "integer" + "picture": { + "type": "string" }, - "unwell": { - "type": "integer" + "sub": { + "description": "OIDC standard claims", + "type": "string" }, - "updatetime": { + "uid": { + "description": "Legacy fields (backward compatible)", "type": "integer" }, "user_id": { @@ -9113,980 +10332,1130 @@ var doc = `{ } } }, - "admin.SystemConfigItem": { + "auth.OIDCBindConfirmRequest": { "type": "object", "properties": { - "key": { + "account": { "type": "string" }, - "value": { + "bind_token": { + "type": "string" + }, + "password": { + "description": "#nosec G117 -- 这是字段定义", "type": "string" } } }, - "admin.UpdateOAuthAppRequest": { + "auth.OIDCBindConfirmResponse": { + "type": "object" + }, + "auth.OIDCBindInfoResponse": { "type": "object", "properties": { - "description": { + "email": { "type": "string" }, "name": { "type": "string" }, - "redirect_uri": { + "picture": { "type": "string" }, - "status": { + "provider_icon": { + "type": "string" + }, + "provider_id": { "type": "integer" + }, + "provider_name": { + "type": "string" } } }, - "admin.UpdateOAuthAppResponse": { - "type": "object" - }, - "admin.UpdateOIDCProviderRequest": { + "auth.OIDCDiscoveryResponse": { "type": "object", "properties": { - "auth_url": { - "type": "string" - }, - "client_id": { - "type": "string" - }, - "client_secret": { - "description": "#nosec G117 -- API 请求字段,非硬编码密码", + "authorization_endpoint": { "type": "string" }, - "display_order": { - "type": "integer" + "grant_types_supported": { + "type": "array", + "items": { + "type": "string" + } }, - "icon": { - "type": "string" + "id_token_signing_alg_values_supported": { + "type": "array", + "items": { + "type": "string" + } }, - "issuer_url": { + "issuer": { "type": "string" }, - "name": { + "jwks_uri": { "type": "string" }, - "scopes": { - "type": "string" + "response_types_supported": { + "type": "array", + "items": { + "type": "string" + } }, - "status": { - "type": "integer" + "scopes_supported": { + "type": "array", + "items": { + "type": "string" + } }, - "token_url": { - "type": "string" + "subject_types_supported": { + "type": "array", + "items": { + "type": "string" + } }, - "type": { + "token_endpoint": { "type": "string" }, - "userinfo_url": { + "userinfo_endpoint": { "type": "string" } } }, - "admin.UpdateOIDCProviderResponse": { - "type": "object" - }, - "admin.UpdateSystemConfigsRequest": { + "auth.OIDCJWKSResponse": { "type": "object", "properties": { - "configs": { + "keys": { "type": "array", "items": { - "$ref": "#/definitions/admin.SystemConfigItem" + "$ref": "#/definitions/auth.JWK" } } } }, - "admin.UpdateSystemConfigsResponse": { - "type": "object" - }, - "admin.UpdateUserAdminLevelRequest": { + "auth.OIDCProviderInfo": { "type": "object", "properties": { - "admin_level": { + "icon": { + "type": "string" + }, + "id": { "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" } } }, - "admin.UpdateUserAdminLevelResponse": { - "type": "object" - }, - "admin.UpdateUserStatusRequest": { + "auth.OIDCProvidersResponse": { "type": "object", "properties": { - "status": { - "type": "integer" + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/auth.OIDCProviderInfo" + } } } }, - "admin.UpdateUserStatusResponse": { - "type": "object" - }, - "admin.UserItem": { + "auth.OIDCRegisterAndBindRequest": { "type": "object", "properties": { - "admin_level": { - "type": "integer" - }, - "avatar": { + "-": { + "description": "由 Controller 层设置,不从请求中读取", "type": "string" }, - "createtime": { - "type": "integer" + "agree_terms": { + "type": "boolean" }, - "email": { + "bind_token": { "type": "string" }, - "id": { - "type": "integer" - }, - "ip_location": { + "code": { "type": "string" }, - "register_ip": { + "email": { "type": "string" }, - "status": { - "type": "integer" + "password": { + "description": "#nosec G117 -- 这是字段定义", + "type": "string" }, "username": { "type": "string" } } }, - "announcement.AdminAnnouncement": { + "auth.OIDCRegisterAndBindResponse": { + "type": "object" + }, + "auth.RegisterRequest": { "type": "object", "properties": { - "content": { - "description": "JSON string", + "-": { + "description": "由 Controller 层设置,不从请求中读取", "type": "string" }, - "createtime": { - "type": "integer" - }, - "id": { - "type": "integer" + "agree_terms": { + "type": "boolean" }, - "level": { - "type": "integer" + "code": { + "type": "string" }, - "status": { - "type": "integer" + "email": { + "type": "string" }, - "title": { - "description": "JSON string", + "password": { + "description": "#nosec G117 -- 这是字段定义", "type": "string" }, - "updatetime": { - "type": "integer" + "username": { + "type": "string" } } }, - "announcement.AdminCreateRequest": { + "auth.RegisterResponse": { "type": "object", "properties": { - "content": { - "type": "string" - }, - "level": { - "type": "integer" - }, - "title": { + "message": { "type": "string" } } }, - "announcement.AdminCreateResponse": { + "auth.ResetPasswordRequest": { "type": "object", "properties": { - "id": { - "type": "integer" + "password": { + "description": "#nosec G117 -- 这是字段定义", + "type": "string" + }, + "token": { + "type": "string" } } }, - "announcement.AdminDeleteRequest": { - "type": "object" - }, - "announcement.AdminDeleteResponse": { - "type": "object" - }, - "announcement.AdminListResponse": { + "auth.ResetPasswordResponse": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/announcement.AdminAnnouncement" - } - }, - "total": { - "type": "integer" + "message": { + "type": "string" } } }, - "announcement.AdminUpdateRequest": { + "auth.SendRegisterCodeRequest": { "type": "object", "properties": { - "content": { + "email": { "type": "string" }, - "level": { - "type": "integer" - }, - "status": { - "type": "integer" - }, - "title": { + "turnstile_token": { "type": "string" } } }, - "announcement.AdminUpdateResponse": { - "type": "object" - }, - "announcement.Announcement": { + "auth.SendRegisterCodeResponse": { "type": "object", "properties": { - "content": { + "message": { "type": "string" - }, + } + } + }, + "auth.UserOAuthBindItem": { + "type": "object", + "properties": { "createtime": { "type": "integer" }, "id": { "type": "integer" }, - "level": { - "type": "integer" + "provider": { + "type": "string" }, - "title": { + "provider_name": { + "type": "string" + }, + "provider_username": { "type": "string" } } }, - "announcement.LatestResponse": { - "type": "object" - }, - "announcement.ListResponse": { + "auth.UserOAuthListResponse": { "type": "object", "properties": { - "list": { + "items": { "type": "array", "items": { - "type": "object", - "$ref": "#/definitions/announcement.Announcement" + "$ref": "#/definitions/auth.UserOAuthBindItem" } - }, - "total": { - "type": "integer" } } }, - "audit.AuditLogItem": { + "auth.UserOAuthUnbindRequest": { + "type": "object" + }, + "auth.UserOAuthUnbindResponse": { + "type": "object" + }, + "auth.WebAuthnCredentialItem": { "type": "object", "properties": { - "action": { - "$ref": "#/definitions/audit_entity.Action" - }, "createtime": { "type": "integer" }, "id": { "type": "integer" }, - "is_admin": { - "type": "boolean" - }, - "reason": { - "type": "string" - }, - "target_id": { - "type": "integer" - }, - "target_name": { - "type": "string" - }, - "target_type": { - "type": "string" - }, - "user_id": { - "type": "integer" - }, - "username": { + "name": { "type": "string" } } }, - "audit.ListResponse": { - "type": "object", - "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/audit.AuditLogItem" - } - }, - "total": { - "type": "integer" - } - } + "auth.WebAuthnDeleteCredentialRequest": { + "type": "object" }, - "audit.ScriptListResponse": { + "auth.WebAuthnDeleteCredentialResponse": { + "type": "object" + }, + "auth.WebAuthnListCredentialsResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { - "type": "object", - "$ref": "#/definitions/audit.AuditLogItem" + "$ref": "#/definitions/auth.WebAuthnCredentialItem" } - }, - "total": { - "type": "integer" } } }, - "audit_entity.Action": { - "description": "Action enum type:\n- ActionScriptCreate: script_create\n- ActionScriptUpdate: script_update\n- ActionScriptDelete: script_delete", - "type": "string", - "enum": [ - "script_create", - "script_update", - "script_delete" - ] - }, - "auth.ForgotPasswordRequest": { + "auth.WebAuthnLoginBeginRequest": { "type": "object", "properties": { - "email": { - "type": "string" - }, - "turnstile_token": { + "session_token": { + "description": "#nosec G117 -- 这是字段定义,非硬编码凭据", "type": "string" } } }, - "auth.ForgotPasswordResponse": { + "auth.WebAuthnLoginBeginResponse": { "type": "object", "properties": { - "message": { - "type": "string" + "options": { + "type": "object" } } }, - "auth.JWK": { + "auth.WebAuthnLoginFinishRequest": { "type": "object", "properties": { - "alg": { - "type": "string" - }, - "e": { - "type": "string" - }, - "kid": { - "type": "string" - }, - "kty": { - "type": "string" - }, - "n": { + "credential": { + "description": "JSON-encoded assertion response", "type": "string" }, - "use": { + "session_token": { + "description": "#nosec G117 -- 这是字段定义,非硬编码凭据", "type": "string" } } }, - "auth.LoginRequest": { + "auth.WebAuthnLoginFinishResponse": { + "type": "object" + }, + "auth.WebAuthnPasswordlessBeginRequest": { + "type": "object" + }, + "auth.WebAuthnPasswordlessBeginResponse": { "type": "object", "properties": { - "account": { + "challenge_id": { "type": "string" }, - "password": { - "description": "#nosec G117 -- 这是字段定义", + "options": { + "type": "object" + } + } + }, + "auth.WebAuthnPasswordlessFinishRequest": { + "type": "object", + "properties": { + "challenge_id": { "type": "string" }, - "turnstile_token": { + "credential": { + "description": "JSON-encoded assertion response", "type": "string" } } }, - "auth.LoginResponse": { + "auth.WebAuthnPasswordlessFinishResponse": { + "type": "object" + }, + "auth.WebAuthnRegisterBeginRequest": { + "type": "object" + }, + "auth.WebAuthnRegisterBeginResponse": { "type": "object", "properties": { - "require_webauthn,omitempty": { - "type": "boolean" + "options": { + "type": "object" }, - "session_token,omitempty": { + "session_id": { "type": "string" } } }, - "auth.OAuth2ApproveRequest": { + "auth.WebAuthnRegisterFinishRequest": { "type": "object", "properties": { - "client_id": { - "type": "string" - }, - "nonce": { + "credential": { + "description": "JSON-encoded attestation response", "type": "string" }, - "redirect_uri": { + "name": { "type": "string" }, - "scope": { + "session_id": { "type": "string" - }, - "state": { + } + } + }, + "auth.WebAuthnRegisterFinishResponse": { + "type": "object", + "properties": { + "message": { "type": "string" } } }, - "auth.OAuth2ApproveResponse": { + "auth.WebAuthnRenameCredentialRequest": { "type": "object", "properties": { - "redirect_uri": { + "name": { "type": "string" } } }, - "auth.OAuth2AuthorizeResponse": { + "auth.WebAuthnRenameCredentialResponse": { + "type": "object" + }, + "chat.CreateSessionRequest": { "type": "object", "properties": { - "app_name": { + "title": { "type": "string" + } + } + }, + "chat.CreateSessionResponse": { + "type": "object" + }, + "chat.DeleteSessionRequest": { + "type": "object" + }, + "chat.DeleteSessionResponse": { + "type": "object" + }, + "chat.ListMessagesResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/chat.Message" + } }, - "client_id": { - "type": "string" + "total": { + "type": "integer" + } + } + }, + "chat.ListSessionsResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/chat.Session" + } }, - "description": { + "total": { + "type": "integer" + } + } + }, + "chat.Message": { + "type": "object", + "properties": { + "content": { "type": "string" }, - "nonce": { - "type": "string" + "createtime": { + "type": "integer" }, - "redirect_uri": { - "type": "string" + "id": { + "type": "integer" }, - "scope": { + "role": { "type": "string" }, - "state": { - "type": "string" + "session_id": { + "type": "integer" } } }, - "auth.OAuth2TokenRequest": { + "chat.Session": { "type": "object", "properties": { - "client_id": { - "type": "string" - }, - "client_secret": { - "description": "#nosec G117 -- API 请求字段,非硬编码密码", - "type": "string" + "createtime": { + "type": "integer" }, - "code": { - "type": "string" + "id": { + "type": "integer" }, - "grant_type": { + "title": { "type": "string" }, - "redirect_uri": { - "type": "string" + "updatetime": { + "type": "integer" } } }, - "auth.OAuth2TokenResponse": { + "httputils.PageRequest": { "type": "object", "properties": { - "access_token": { - "description": "#nosec G117 -- API 响应字段,非硬编码密码", + "order": { + "description": "Deprecated 请使用方法GetOrder", "type": "string" }, - "expires_in": { + "page": { + "description": "Deprecated 请使用方法GetPage", "type": "integer" }, - "id_token,omitempty": { - "type": "string" - }, - "scope,omitempty": { - "type": "string" + "size": { + "description": "Deprecated 请使用方法GetSize", + "type": "integer" }, - "token_type": { + "sort": { + "description": "Deprecated 请使用方法GetSort", "type": "string" } } }, - "auth.OAuth2UserInfoResponse": { + "httputils.PageResponse": { "type": "object", "properties": { - "avatar": { - "type": "string" - }, - "email": { - "type": "string" + "list": { + "type": "array", + "items": { + "type": "any" + } }, - "name": { + "total": { + "type": "integer" + } + } + }, + "issue.Comment": { + "type": "object", + "properties": { + "content": { "type": "string" }, - "picture": { - "type": "string" + "createtime": { + "type": "integer" }, - "sub": { - "description": "OIDC standard claims", - "type": "string" + "id": { + "type": "integer" }, - "uid": { - "description": "Legacy fields (backward compatible)", + "issue_id": { "type": "integer" }, - "user_id": { + "status": { "type": "integer" }, - "username": { - "type": "string" + "type": { + "$ref": "#/definitions/issue_entity.CommentType" + }, + "updatetime": { + "type": "integer" } } }, - "auth.OIDCBindConfirmRequest": { + "issue.CreateCommentRequest": { "type": "object", "properties": { - "account": { - "type": "string" - }, - "bind_token": { - "type": "string" - }, - "password": { - "description": "#nosec G117 -- 这是字段定义", + "content": { "type": "string" } } }, - "auth.OIDCBindConfirmResponse": { + "issue.CreateCommentResponse": { "type": "object" }, - "auth.OIDCBindInfoResponse": { + "issue.CreateIssueRequest": { "type": "object", "properties": { - "email": { - "type": "string" - }, - "name": { + "content": { "type": "string" }, - "picture": { - "type": "string" + "labels": { + "type": "array", + "items": { + "type": "string" + } }, - "provider_icon": { + "title": { "type": "string" - }, - "provider_id": { + } + } + }, + "issue.CreateIssueResponse": { + "type": "object", + "properties": { + "id": { "type": "integer" - }, - "provider_name": { - "type": "string" } } }, - "auth.OIDCDiscoveryResponse": { + "issue.DeleteCommentRequest": { + "type": "object" + }, + "issue.DeleteCommentResponse": { + "type": "object" + }, + "issue.DeleteRequest": { + "type": "object" + }, + "issue.DeleteResponse": { + "type": "object" + }, + "issue.GetIssueResponse": { "type": "object", "properties": { - "authorization_endpoint": { + "content": { "type": "string" + } + } + }, + "issue.GetWatchResponse": { + "type": "object", + "properties": { + "watch": { + "type": "boolean" + } + } + }, + "issue.Issue": { + "type": "object", + "properties": { + "comment_count": { + "type": "integer" }, - "grant_types_supported": { - "type": "array", - "items": { - "type": "string" - } - }, - "id_token_signing_alg_values_supported": { - "type": "array", - "items": { - "type": "string" - } - }, - "issuer": { - "type": "string" + "createtime": { + "type": "integer" }, - "jwks_uri": { - "type": "string" + "id": { + "type": "integer" }, - "response_types_supported": { + "labels": { "type": "array", "items": { "type": "string" } }, - "scopes_supported": { - "type": "array", - "items": { - "type": "string" - } + "script_id": { + "type": "integer" }, - "subject_types_supported": { - "type": "array", - "items": { - "type": "string" - } + "status": { + "type": "integer" }, - "token_endpoint": { + "title": { "type": "string" }, - "userinfo_endpoint": { - "type": "string" + "updatetime": { + "type": "integer" } } }, - "auth.OIDCJWKSResponse": { + "issue.ListCommentResponse": { "type": "object", "properties": { - "keys": { + "list": { "type": "array", "items": { - "$ref": "#/definitions/auth.JWK" + "type": "object", + "$ref": "#/definitions/issue.Comment" } + }, + "total": { + "type": "integer" } } }, - "auth.OIDCProviderInfo": { + "issue.ListResponse": { "type": "object", "properties": { - "icon": { - "type": "string" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/issue.Issue" + } }, - "id": { + "total": { "type": "integer" + } + } + }, + "issue.OpenRequest": { + "type": "object", + "properties": { + "close": { + "description": "true:关闭 false:打开", + "type": "boolean" }, - "name": { - "type": "string" - }, - "type": { + "content": { "type": "string" } } }, - "auth.OIDCProvidersResponse": { + "issue.OpenResponse": { "type": "object", "properties": { - "providers": { + "comments": { "type": "array", "items": { - "$ref": "#/definitions/auth.OIDCProviderInfo" + "$ref": "#/definitions/issue.Comment" } } } }, - "auth.OIDCRegisterAndBindRequest": { + "issue.UpdateLabelsRequest": { "type": "object", "properties": { - "agree_terms": { - "type": "boolean" - }, - "bind_token": { - "type": "string" - }, - "code": { - "type": "string" - }, - "email": { - "type": "string" - }, - "password": { - "description": "#nosec G117 -- 这是字段定义", - "type": "string" - }, - "username": { - "type": "string" + "labels": { + "type": "array", + "items": { + "type": "string" + } } } }, - "auth.OIDCRegisterAndBindResponse": { + "issue.UpdateLabelsResponse": { "type": "object" }, - "auth.RegisterRequest": { + "issue.WatchRequest": { "type": "object", "properties": { - "-": { - "description": "由 Controller 层设置,不从请求中读取", - "type": "string" - }, - "agree_terms": { + "watch": { "type": "boolean" - }, - "code": { - "type": "string" - }, - "email": { - "type": "string" - }, - "password": { - "description": "#nosec G117 -- 这是字段定义", - "type": "string" - }, - "username": { - "type": "string" } } }, - "auth.RegisterResponse": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - } + "issue.WatchResponse": { + "type": "object" }, - "auth.ResetPasswordRequest": { + "issue_entity.CommentType": { + "description": "CommentType enum type:\n- CommentTypeComment: 1\n- CommentTypeChangeTitle: 2\n- CommentTypeChangeLabel: 3\n- CommentTypeOpen: 4\n- CommentTypeClose: 5\n- CommentTypeDelete: 6", + "type": "integer", + "enum": [ + 1, + 2, + 3, + 4, + 5, + 6 + ] + }, + "model.AdminLevel": { + "description": "AdminLevel enum type:\n- Admin: 1\n- SuperModerator: 2\n- Moderator: 3", + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + }, + "notification.BatchMarkReadRequest": { "type": "object", "properties": { - "password": { - "description": "#nosec G117 -- 这是字段定义", - "type": "string" - }, - "token": { - "type": "string" + "ids": { + "description": "通知ID列表,为空则全部标记已读", + "type": "array", + "items": { + "type": "integer" + } } } }, - "auth.ResetPasswordResponse": { + "notification.BatchMarkReadResponse": { + "type": "object" + }, + "notification.GetUnreadCountResponse": { "type": "object", "properties": { - "message": { - "type": "string" + "total": { + "description": "总未读数", + "type": "integer" } } }, - "auth.SendRegisterCodeRequest": { + "notification.ListResponse": { "type": "object", "properties": { - "email": { - "type": "string" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/notification.Notification" + } }, - "turnstile_token": { - "type": "string" + "total": { + "type": "integer" } } }, - "auth.SendRegisterCodeResponse": { + "notification.MarkReadRequest": { "type": "object", "properties": { - "message": { - "type": "string" + "unread": { + "type": "integer" } } }, - "auth.UserOAuthBindItem": { + "notification.MarkReadResponse": { + "type": "object" + }, + "notification.Notification": { "type": "object", "properties": { + "content": { + "description": "通知内容", + "type": "string" + }, "createtime": { "type": "integer" }, + "from_user,omitempty": { + "description": "发起用户信息", + "$ref": "#/definitions/user_entity.UserInfo" + }, "id": { "type": "integer" }, - "provider": { + "link": { + "description": "通知链接", "type": "string" }, - "provider_name": { - "type": "string" + "params,omitempty": { + "description": "额外参数", + "type": "object" }, - "provider_username": { + "read_status": { + "description": "0:未读 1:已读", + "type": "integer" + }, + "read_time,omitempty": { + "description": "阅读时间", + "type": "integer" + }, + "title": { + "description": "通知标题", "type": "string" + }, + "type": { + "description": "通知类型", + "$ref": "#/definitions/notification_entity.Type" + }, + "updatetime": { + "type": "integer" + }, + "user_id": { + "type": "integer" } } }, - "auth.UserOAuthListResponse": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/auth.UserOAuthBindItem" - } - } - } - }, - "auth.UserOAuthUnbindRequest": { - "type": "object" + "notification_entity.Type": { + "description": "Type enum type:\n- ScriptUpdateTemplate: 100\n- IssueCreateTemplate: 101\n- CommentCreateTemplate: 102\n- ScriptScoreTemplate: 103\n- AccessInviteTemplate: 104\n- ScriptScoreReplyTemplate: 105\n- ReportCreateTemplate: 106\n- ReportCommentTemplate: 107\n- ScriptDeleteTemplate: 108", + "type": "integer", + "enum": [ + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108 + ] }, - "auth.UserOAuthUnbindResponse": { + "open.CrxDownloadResponse": { "type": "object" }, - "auth.WebAuthnCredentialItem": { + "report.Comment": { "type": "object", "properties": { + "content": { + "type": "string" + }, "createtime": { "type": "integer" }, "id": { "type": "integer" }, - "name": { + "report_id": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/report_entity.CommentType" + }, + "updatetime": { + "type": "integer" + } + } + }, + "report.CreateCommentRequest": { + "type": "object", + "properties": { + "content": { "type": "string" } } }, - "auth.WebAuthnDeleteCredentialRequest": { + "report.CreateCommentResponse": { "type": "object" }, - "auth.WebAuthnDeleteCredentialResponse": { - "type": "object" + "report.CreateReportRequest": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "reason": { + "type": "string" + } + } }, - "auth.WebAuthnListCredentialsResponse": { + "report.CreateReportResponse": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "$ref": "#/definitions/auth.WebAuthnCredentialItem" - } + "id": { + "type": "integer" } } }, - "auth.WebAuthnLoginBeginRequest": { + "report.DeleteCommentRequest": { + "type": "object" + }, + "report.DeleteCommentResponse": { + "type": "object" + }, + "report.DeleteRequest": { + "type": "object" + }, + "report.DeleteResponse": { + "type": "object" + }, + "report.GetReportResponse": { "type": "object", "properties": { - "session_token": { - "description": "#nosec G117 -- 这是字段定义,非硬编码凭据", + "content": { "type": "string" } } }, - "auth.WebAuthnLoginBeginResponse": { + "report.ListCommentResponse": { "type": "object", "properties": { - "options": { - "type": "object" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/report.Comment" + } + }, + "total": { + "type": "integer" } } }, - "auth.WebAuthnLoginFinishRequest": { + "report.ListResponse": { "type": "object", "properties": { - "credential": { - "description": "JSON-encoded assertion response", - "type": "string" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/report.Report" + } }, - "session_token": { - "description": "#nosec G117 -- 这是字段定义,非硬编码凭据", - "type": "string" + "total": { + "type": "integer" } } }, - "auth.WebAuthnLoginFinishResponse": { - "type": "object" - }, - "auth.WebAuthnPasswordlessBeginRequest": { - "type": "object" - }, - "auth.WebAuthnPasswordlessBeginResponse": { + "report.Report": { "type": "object", "properties": { - "challenge_id": { + "comment_count": { + "type": "integer" + }, + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "reason": { "type": "string" }, - "options": { - "type": "object" + "script_id": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "updatetime": { + "type": "integer" } } }, - "auth.WebAuthnPasswordlessFinishRequest": { + "report.ResolveRequest": { "type": "object", "properties": { - "challenge_id": { - "type": "string" + "close": { + "description": "true:解决 false:重新打开", + "type": "boolean" }, - "credential": { - "description": "JSON-encoded assertion response", + "content": { "type": "string" } } }, - "auth.WebAuthnPasswordlessFinishResponse": { - "type": "object" + "report.ResolveResponse": { + "type": "object", + "properties": { + "comments": { + "type": "array", + "items": { + "$ref": "#/definitions/report.Comment" + } + } + } }, - "auth.WebAuthnRegisterBeginRequest": { - "type": "object" + "report_entity.CommentType": { + "description": "CommentType enum type:\n- CommentTypeComment: 1\n- CommentTypeResolve: 2\n- CommentTypeReopen: 3", + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] }, - "auth.WebAuthnRegisterBeginResponse": { + "resource.UploadImageRequest": { "type": "object", "properties": { - "options": { - "type": "object" - }, - "session_id": { + "comment": { "type": "string" + }, + "link_id": { + "type": "integer" } } }, - "auth.WebAuthnRegisterFinishRequest": { + "resource.UploadImageResponse": { "type": "object", "properties": { - "credential": { - "description": "JSON-encoded attestation response", + "comment": { "type": "string" }, - "name": { + "content_type": { "type": "string" }, - "session_id": { + "createtime": { + "type": "integer" + }, + "id": { "type": "string" - } - } - }, - "auth.WebAuthnRegisterFinishResponse": { - "type": "object", - "properties": { - "message": { + }, + "link_id": { + "type": "integer" + }, + "name": { "type": "string" } } }, - "auth.WebAuthnRenameCredentialRequest": { + "script.AcceptInviteRequest": { "type": "object", "properties": { - "name": { - "type": "string" + "accept": { + "description": "邀请码类型不能拒绝", + "type": "boolean" } } }, - "auth.WebAuthnRenameCredentialResponse": { + "script.AcceptInviteResponse": { "type": "object" }, - "chat.CreateSessionRequest": { + "script.Access": { "type": "object", "properties": { - "title": { + "avatar": { + "type": "string" + }, + "createtime": { + "type": "integer" + }, + "expiretime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "invite_status": { + "description": "邀请状态 1=已接受 2=已拒绝 3=待接受", + "$ref": "#/definitions/script_entity.AccessInviteStatus" + }, + "is_expire": { + "type": "boolean" + }, + "link_id": { + "description": "关联id", + "type": "integer" + }, + "name": { "type": "string" + }, + "role": { + "$ref": "#/definitions/script_entity.AccessRole" + }, + "type": { + "description": "id类型 1=用户id 2=组id", + "$ref": "#/definitions/script_entity.AccessType" } } }, - "chat.CreateSessionResponse": { - "type": "object" - }, - "chat.DeleteSessionRequest": { - "type": "object" - }, - "chat.DeleteSessionResponse": { - "type": "object" - }, - "chat.ListMessagesResponse": { + "script.AccessListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/chat.Message" + "$ref": "#/definitions/script.Access" } }, "total": { @@ -10094,168 +11463,160 @@ var doc = `{ } } }, - "chat.ListSessionsResponse": { + "script.AddGroupAccessRequest": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/chat.Session" - } + "expiretime,default=0": { + "description": "0 为永久", + "type": "integer" }, - "total": { + "group_id": { "type": "integer" + }, + "role": { + "description": "访问权限 guest=访客 manager=管理员", + "$ref": "#/definitions/script_entity.AccessRole" } } }, - "chat.Message": { + "script.AddGroupAccessResponse": { + "type": "object" + }, + "script.AddMemberRequest": { "type": "object", "properties": { - "content": { - "type": "string" - }, - "createtime": { - "type": "integer" - }, - "id": { + "expiretime": { "type": "integer" }, - "role": { - "type": "string" - }, - "session_id": { + "user_id": { "type": "integer" } } }, - "chat.Session": { + "script.AddMemberResponse": { + "type": "object" + }, + "script.AddUserAccessRequest": { "type": "object", "properties": { - "createtime": { - "type": "integer" - }, - "id": { + "expiretime,default=0": { + "description": "0 为永久", "type": "integer" }, - "title": { - "type": "string" + "role": { + "description": "访问权限 guest=访客 manager=管理员", + "$ref": "#/definitions/script_entity.AccessRole" }, - "updatetime": { + "user_id": { "type": "integer" } } }, - "httputils.PageRequest": { + "script.AddUserAccessResponse": { + "type": "object" + }, + "script.ArchiveRequest": { "type": "object", "properties": { - "order": { - "description": "Deprecated 请使用方法GetOrder", - "type": "string" - }, - "page": { - "description": "Deprecated 请使用方法GetPage", - "type": "integer" - }, - "size": { - "description": "Deprecated 请使用方法GetSize", - "type": "integer" - }, - "sort": { - "description": "Deprecated 请使用方法GetSort", - "type": "string" + "archive": { + "type": "boolean" } } }, - "httputils.PageResponse": { + "script.ArchiveResponse": { + "type": "object" + }, + "script.AuditInviteCodeRequest": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "any" - } - }, - "total": { + "status": { + "description": "1=通过 2=拒绝", "type": "integer" } } }, - "issue.Comment": { + "script.AuditInviteCodeResponse": { + "type": "object" + }, + "script.CategoryListItem": { "type": "object", "properties": { - "content": { - "type": "string" - }, "createtime": { "type": "integer" }, "id": { "type": "integer" }, - "issue_id": { + "name": { + "type": "string" + }, + "num": { + "description": "本分类下脚本数量", "type": "integer" }, - "status": { + "sort": { "type": "integer" }, "type": { - "$ref": "#/definitions/issue_entity.CommentType" + "description": "1:脚本分类 2:脚本标签", + "$ref": "#/definitions/script_entity.ScriptCategoryType" }, "updatetime": { "type": "integer" } } }, - "issue.CreateCommentRequest": { + "script.CategoryListResponse": { "type": "object", "properties": { - "content": { - "type": "string" + "categories": { + "description": "分类列表", + "type": "array", + "items": { + "$ref": "#/definitions/script.CategoryListItem" + } } } }, - "issue.CreateCommentResponse": { - "type": "object" - }, - "issue.CreateIssueRequest": { + "script.Code": { "type": "object", "properties": { - "content": { + "changelog": { "type": "string" }, - "labels": { - "type": "array", - "items": { - "type": "string" - } + "code,omitempty": { + "type": "string" }, - "title": { + "createtime": { + "type": "integer" + }, + "definition,omitempty": { "type": "string" - } - } - }, - "issue.CreateIssueResponse": { - "type": "object", - "properties": { + }, "id": { "type": "integer" - } - } - }, - "issue.DeleteCommentRequest": { - "type": "object" - }, - "issue.DeleteCommentResponse": { - "type": "object" - }, - "issue.DeleteRequest": { - "type": "object" - }, - "issue.DeleteResponse": { - "type": "object" + }, + "is_pre_release": { + "$ref": "#/definitions/script_entity.EnablePreRelease" + }, + "meta,omitempty": { + "type": "string" + }, + "meta_json": { + "type": "object" + }, + "script_id": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "version": { + "type": "string" + } + } }, - "issue.GetIssueResponse": { + "script.CodeResponse": { "type": "object", "properties": { "content": { @@ -10263,177 +11624,271 @@ var doc = `{ } } }, - "issue.GetWatchResponse": { + "script.CreateFolderRequest": { "type": "object", "properties": { - "watch": { - "type": "boolean" + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "private": { + "description": "1私密 2公开", + "type": "integer" } } }, - "issue.Issue": { + "script.CreateFolderResponse": { "type": "object", "properties": { - "comment_count": { - "type": "integer" - }, - "createtime": { - "type": "integer" - }, "id": { "type": "integer" + } + } + }, + "script.CreateGroupInviteCodeRequest": { + "type": "object", + "properties": { + "audit": { + "type": "boolean" }, - "labels": { - "type": "array", - "items": { - "type": "string" - } - }, - "script_id": { - "type": "integer" - }, - "status": { + "count": { "type": "integer" }, - "title": { - "type": "string" - }, - "updatetime": { + "days": { + "description": "0 为永久", "type": "integer" } } }, - "issue.ListCommentResponse": { + "script.CreateGroupInviteCodeResponse": { "type": "object", "properties": { - "list": { + "code": { "type": "array", "items": { - "type": "object", - "$ref": "#/definitions/issue.Comment" + "type": "string" } - }, - "total": { - "type": "integer" } } }, - "issue.ListResponse": { + "script.CreateGroupRequest": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/issue.Issue" - } + "description": { + "type": "string" }, - "total": { - "type": "integer" + "name": { + "type": "string" } } }, - "issue.OpenRequest": { + "script.CreateGroupResponse": { + "type": "object" + }, + "script.CreateInviteCodeRequest": { "type": "object", "properties": { - "close": { - "description": "true:关闭 false:打开", + "audit": { "type": "boolean" }, - "content": { - "type": "string" + "count": { + "type": "integer" + }, + "days": { + "description": "0 为永久", + "type": "integer" } } }, - "issue.OpenResponse": { + "script.CreateInviteCodeResponse": { "type": "object", "properties": { - "comments": { + "code": { "type": "array", "items": { - "$ref": "#/definitions/issue.Comment" + "type": "string" } } } }, - "issue.UpdateLabelsRequest": { + "script.CreateRequest": { "type": "object", "properties": { - "labels": { + "category": { + "description": "分类ID", + "type": "integer" + }, + "changelog": { + "type": "string" + }, + "code": { + "type": "string" + }, + "content": { + "type": "string" + }, + "definition": { + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "public": { + "description": "公开类型:1 公开 2 半公开 3 私有", + "$ref": "#/definitions/script_entity.Public" + }, + "tags": { + "description": "标签,只有脚本类型为库时才有意义", "type": "array", "items": { "type": "string" } + }, + "type": { + "description": "脚本类型:1 用户脚本 2 订阅脚本(不支持) 3 脚本引用库", + "$ref": "#/definitions/script_entity.Type" + }, + "unwell": { + "description": "不适内容: 1 不适 2 适用", + "$ref": "#/definitions/script_entity.UnwellContent" + }, + "version": { + "type": "string" } } }, - "issue.UpdateLabelsResponse": { + "script.CreateResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, + "script.DelScoreRequest": { "type": "object" }, - "issue.WatchRequest": { + "script.DelScoreResponse": { + "type": "object" + }, + "script.DeleteAccessRequest": { + "type": "object" + }, + "script.DeleteAccessResponse": { + "type": "object" + }, + "script.DeleteCodeRequest": { + "type": "object" + }, + "script.DeleteCodeResponse": { + "type": "object" + }, + "script.DeleteFolderRequest": { + "type": "object" + }, + "script.DeleteFolderResponse": { + "type": "object" + }, + "script.DeleteGroupRequest": { + "type": "object" + }, + "script.DeleteGroupResponse": { + "type": "object" + }, + "script.DeleteInviteCodeRequest": { + "type": "object" + }, + "script.DeleteInviteCodeResponse": { + "type": "object" + }, + "script.DeleteRequest": { "type": "object", "properties": { - "watch": { - "type": "boolean" + "reason": { + "description": "删除原因(可选)", + "type": "string" } } }, - "issue.WatchResponse": { + "script.DeleteResponse": { "type": "object" }, - "issue_entity.CommentType": { - "description": "CommentType enum type:\n- CommentTypeComment: 1\n- CommentTypeChangeTitle: 2\n- CommentTypeChangeLabel: 3\n- CommentTypeOpen: 4\n- CommentTypeClose: 5\n- CommentTypeDelete: 6", - "type": "integer", - "enum": [ - 1, - 2, - 3, - 4, - 5, - 6 - ] + "script.EditFolderRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "private": { + "description": "1私密 2公开", + "type": "integer" + } + } }, - "model.AdminLevel": { - "description": "AdminLevel enum type:\n- Admin: 1\n- SuperModerator: 2\n- Moderator: 3", - "type": "integer", - "enum": [ - 1, - 2, - 3 - ] + "script.EditFolderResponse": { + "type": "object" + }, + "script.FavoriteFolderDetailResponse": { + "type": "object" + }, + "script.FavoriteFolderItem": { + "type": "object", + "properties": { + "count": { + "description": "收藏夹中脚本数量", + "type": "integer" + }, + "description": { + "description": "收藏夹描述", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "private": { + "description": "收藏夹类型 1私密 2公开", + "type": "integer" + }, + "updatetime": { + "description": "收藏夹更新时间", + "type": "integer" + } + } }, - "notification.BatchMarkReadRequest": { + "script.FavoriteFolderListResponse": { "type": "object", "properties": { - "ids": { - "description": "通知ID列表,为空则全部标记已读", + "list": { "type": "array", "items": { - "type": "integer" + "type": "object", + "$ref": "#/definitions/script.FavoriteFolderItem" } - } - } - }, - "notification.BatchMarkReadResponse": { - "type": "object" - }, - "notification.GetUnreadCountResponse": { - "type": "object", - "properties": { + }, "total": { - "description": "总未读数", "type": "integer" } } }, - "notification.ListResponse": { + "script.FavoriteScriptListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/notification.Notification" + "$ref": "#/definitions/script.Script" } }, "total": { @@ -10441,168 +11896,77 @@ var doc = `{ } } }, - "notification.MarkReadRequest": { + "script.FavoriteScriptRequest": { "type": "object", "properties": { - "unread": { + "script_id": { "type": "integer" } } }, - "notification.MarkReadResponse": { + "script.FavoriteScriptResponse": { "type": "object" }, - "notification.Notification": { + "script.GetSettingResponse": { "type": "object", "properties": { - "content": { - "description": "通知内容", + "content_url": { "type": "string" }, - "createtime": { - "type": "integer" - }, - "from_user,omitempty": { - "description": "发起用户信息", - "$ref": "#/definitions/user_entity.UserInfo" - }, - "id": { - "type": "integer" - }, - "link": { - "description": "通知链接", + "definition_url": { "type": "string" }, - "params,omitempty": { - "description": "额外参数", - "type": "object" + "enable_pre_release": { + "$ref": "#/definitions/script_entity.EnablePreRelease" }, - "read_status": { - "description": "0:未读 1:已读", - "type": "integer" + "gray_controls": { + "type": "array", + "items": { + "$ref": "#/definitions/script_entity.GrayControl" + } }, - "read_time,omitempty": { - "description": "阅读时间", - "type": "integer" + "sync_mode": { + "$ref": "#/definitions/script_entity.SyncMode" }, - "title": { - "description": "通知标题", + "sync_url": { "type": "string" - }, - "type": { - "description": "通知类型", - "$ref": "#/definitions/notification_entity.Type" - }, - "updatetime": { - "type": "integer" - }, - "user_id": { - "type": "integer" } } }, - "notification_entity.Type": { - "description": "Type enum type:\n- ScriptUpdateTemplate: 100\n- IssueCreateTemplate: 101\n- CommentCreateTemplate: 102\n- ScriptScoreTemplate: 103\n- AccessInviteTemplate: 104\n- ScriptScoreReplyTemplate: 105\n- ReportCreateTemplate: 106\n- ReportCommentTemplate: 107\n- ScriptDeleteTemplate: 108", - "type": "integer", - "enum": [ - 100, - 101, - 102, - 103, - 104, - 105, - 106, - 107, - 108 - ] - }, - "open.CrxDownloadResponse": { - "type": "object" - }, - "report.Comment": { + "script.Group": { "type": "object", "properties": { - "content": { - "type": "string" - }, "createtime": { "type": "integer" }, - "id": { - "type": "integer" - }, - "report_id": { - "type": "integer" + "description": { + "type": "string" }, - "status": { + "id": { "type": "integer" }, - "type": { - "$ref": "#/definitions/report_entity.CommentType" + "member": { + "type": "array", + "items": { + "$ref": "#/definitions/script.GroupMember" + } }, - "updatetime": { + "member_count": { "type": "integer" - } - } - }, - "report.CreateCommentRequest": { - "type": "object", - "properties": { - "content": { - "type": "string" - } - } - }, - "report.CreateCommentResponse": { - "type": "object" - }, - "report.CreateReportRequest": { - "type": "object", - "properties": { - "content": { - "type": "string" }, - "reason": { - "type": "string" - } - } - }, - "report.CreateReportResponse": { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - "report.DeleteCommentRequest": { - "type": "object" - }, - "report.DeleteCommentResponse": { - "type": "object" - }, - "report.DeleteRequest": { - "type": "object" - }, - "report.DeleteResponse": { - "type": "object" - }, - "report.GetReportResponse": { - "type": "object", - "properties": { - "content": { + "name": { "type": "string" } } }, - "report.ListCommentResponse": { + "script.GroupInviteCodeListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/report.Comment" + "$ref": "#/definitions/script.InviteCode" } }, "total": { @@ -10610,14 +11974,14 @@ var doc = `{ } } }, - "report.ListResponse": { + "script.GroupListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/report.Report" + "$ref": "#/definitions/script.Group" } }, "total": { @@ -10625,156 +11989,159 @@ var doc = `{ } } }, - "report.Report": { + "script.GroupMember": { "type": "object", "properties": { - "comment_count": { - "type": "integer" + "avatar": { + "type": "string" }, "createtime": { "type": "integer" }, - "id": { + "expiretime": { "type": "integer" }, - "reason": { - "type": "string" - }, - "script_id": { + "id": { "type": "integer" }, - "status": { - "type": "integer" + "invite_status": { + "$ref": "#/definitions/script_entity.AccessInviteStatus" }, - "updatetime": { - "type": "integer" - } - } - }, - "report.ResolveRequest": { - "type": "object", - "properties": { - "close": { - "description": "true:解决 false:重新打开", + "is_expire": { "type": "boolean" }, - "content": { + "user_id": { + "type": "integer" + }, + "username": { "type": "string" } } }, - "report.ResolveResponse": { + "script.GroupMemberListResponse": { "type": "object", "properties": { - "comments": { + "list": { "type": "array", "items": { - "$ref": "#/definitions/report.Comment" + "type": "object", + "$ref": "#/definitions/script.GroupMember" } + }, + "total": { + "type": "integer" } } }, - "report_entity.CommentType": { - "description": "CommentType enum type:\n- CommentTypeComment: 1\n- CommentTypeResolve: 2\n- CommentTypeReopen: 3", - "type": "integer", - "enum": [ - 1, - 2, - 3 - ] - }, - "resource.UploadImageRequest": { + "script.InfoResponse": { "type": "object", "properties": { - "comment": { + "content": { "type": "string" }, - "link_id": { - "type": "integer" + "role": { + "$ref": "#/definitions/script_entity.AccessRole" + }, + "sri,omitempty": { + "description": "如果是库的话,会返回sha512 sri", + "type": "string" } } }, - "resource.UploadImageResponse": { + "script.InviteCode": { "type": "object", "properties": { - "comment": { - "type": "string" - }, - "content_type": { + "code": { + "description": "邀请码", "type": "string" }, "createtime": { "type": "integer" }, + "expiretime": { + "description": "到期时间", + "type": "integer" + }, "id": { - "type": "string" + "type": "integer" }, - "link_id": { + "invite_status": { + "$ref": "#/definitions/script_entity.InviteStatus" + }, + "is_audit": { + "description": "是否需要审核", + "type": "boolean" + }, + "used": { + "description": "使用用户", "type": "integer" }, - "name": { + "username": { + "description": "使用用户名", "type": "string" } } }, - "script.AcceptInviteRequest": { + "script.InviteCodeInfoAccess": { "type": "object", "properties": { - "accept": { - "description": "邀请码类型不能拒绝", - "type": "boolean" + "role": { + "description": "访问权限 guest=访客 manager=管理员", + "$ref": "#/definitions/script_entity.AccessRole" } } }, - "script.AcceptInviteResponse": { - "type": "object" - }, - "script.Access": { + "script.InviteCodeInfoGroup": { "type": "object", "properties": { - "avatar": { + "description": { "type": "string" }, - "createtime": { - "type": "integer" + "name": { + "type": "string" + } + } + }, + "script.InviteCodeInfoResponse": { + "type": "object", + "properties": { + "access,omitempty": { + "description": "如果type=1, 则返回权限信息", + "$ref": "#/definitions/script.InviteCodeInfoAccess" }, - "expiretime": { - "type": "integer" + "code_type": { + "description": "邀请码类型 1=邀请码 2=邀请链接", + "$ref": "#/definitions/script_entity.InviteCodeType" }, - "id": { - "type": "integer" + "group,omitempty": { + "description": "如果type=2, 则返回群组信息", + "$ref": "#/definitions/script.InviteCodeInfoGroup" }, "invite_status": { - "description": "邀请状态 1=已接受 2=已拒绝 3=待接受", - "$ref": "#/definitions/script_entity.AccessInviteStatus" + "description": "使用状态", + "$ref": "#/definitions/script_entity.InviteStatus" }, - "is_expire": { + "is_audit": { + "description": "是否需要审核 邀请码类型为邀请链接时,该字段固定为false", "type": "boolean" }, - "link_id": { - "description": "关联id", - "type": "integer" - }, - "name": { - "type": "string" - }, - "role": { - "$ref": "#/definitions/script_entity.AccessRole" + "script": { + "$ref": "#/definitions/script.Script" }, "type": { - "description": "id类型 1=用户id 2=组id", - "$ref": "#/definitions/script_entity.AccessType" + "description": "邀请类型 1=权限邀请码 2=群组邀请码", + "$ref": "#/definitions/script_entity.InviteType" } } }, - "script.AccessListResponse": { + "script.InviteCodeListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/script.Access" + "$ref": "#/definitions/script.InviteCode" } }, "total": { @@ -10782,260 +12149,283 @@ var doc = `{ } } }, - "script.AddGroupAccessRequest": { + "script.LastScoreResponse": { "type": "object", "properties": { - "expiretime,default=0": { - "description": "0 为永久", - "type": "integer" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/script.Script" + } }, - "group_id": { + "total": { "type": "integer" - }, - "role": { - "description": "访问权限 guest=访客 manager=管理员", - "$ref": "#/definitions/script_entity.AccessRole" } } }, - "script.AddGroupAccessResponse": { - "type": "object" - }, - "script.AddMemberRequest": { + "script.ListResponse": { "type": "object", "properties": { - "expiretime": { - "type": "integer" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/script.Script" + } }, - "user_id": { + "total": { "type": "integer" } } }, - "script.AddMemberResponse": { + "script.MigrateEsRequest": { "type": "object" }, - "script.AddUserAccessRequest": { + "script.MigrateEsResponse": { + "type": "object" + }, + "script.PutScoreRequest": { "type": "object", "properties": { - "expiretime,default=0": { - "description": "0 为永久", - "type": "integer" - }, - "role": { - "description": "访问权限 guest=访客 manager=管理员", - "$ref": "#/definitions/script_entity.AccessRole" + "message": { + "type": "string" }, - "user_id": { + "score": { "type": "integer" } } }, - "script.AddUserAccessResponse": { - "type": "object" - }, - "script.ArchiveRequest": { + "script.PutScoreResponse": { "type": "object", "properties": { - "archive": { - "type": "boolean" + "id": { + "type": "integer" } } }, - "script.ArchiveResponse": { + "script.RecordVisitRequest": { "type": "object" }, - "script.AuditInviteCodeRequest": { + "script.RecordVisitResponse": { + "type": "object" + }, + "script.RemoveMemberRequest": { + "type": "object" + }, + "script.RemoveMemberResponse": { + "type": "object" + }, + "script.ReplyScoreRequest": { "type": "object", "properties": { - "status": { - "description": "1=通过 2=拒绝", + "commentID": { "type": "integer" + }, + "message": { + "type": "string" } } }, - "script.AuditInviteCodeResponse": { + "script.ReplyScoreResponse": { "type": "object" }, - "script.CategoryListItem": { + "script.Score": { "type": "object", "properties": { + "author_message": { + "type": "string" + }, + "author_message_createtime": { + "type": "integer" + }, "createtime": { "type": "integer" }, "id": { "type": "integer" }, - "name": { + "message": { "type": "string" }, - "num": { - "description": "本分类下脚本数量", + "score": { "type": "integer" }, - "sort": { + "script_id": { "type": "integer" }, - "type": { - "description": "1:脚本分类 2:脚本标签", - "$ref": "#/definitions/script_entity.ScriptCategoryType" + "state": { + "type": "integer" }, "updatetime": { "type": "integer" } } }, - "script.CategoryListResponse": { + "script.ScoreListResponse": { "type": "object", "properties": { - "categories": { - "description": "分类列表", + "list": { "type": "array", "items": { - "$ref": "#/definitions/script.CategoryListItem" + "type": "object", + "$ref": "#/definitions/script.Score" + } + }, + "total": { + "type": "integer" + } + } + }, + "script.ScoreStateResponse": { + "type": "object", + "properties": { + "score_group": { + "description": "每个评分的数量", + "type": "object", + "additionalProperties": { + "type": "integer" } + }, + "score_user_count": { + "description": "评分人数", + "type": "integer" } } }, - "script.Code": { + "script.Script": { "type": "object", "properties": { - "changelog": { - "type": "string" + "archive": { + "type": "integer" }, - "code,omitempty": { - "type": "string" + "category": { + "$ref": "#/definitions/script.CategoryListItem" }, "createtime": { "type": "integer" }, - "definition,omitempty": { + "danger": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "enable_pre_release": { + "$ref": "#/definitions/script_entity.EnablePreRelease" + }, + "id": { + "type": "integer" + }, + "name": { "type": "string" }, - "id": { + "post_id": { + "type": "integer" + }, + "public": { + "type": "integer" + }, + "score": { + "type": "integer" + }, + "score_num": { + "type": "integer" + }, + "script": { + "$ref": "#/definitions/script.Code" + }, + "status": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/script.CategoryListItem" + } + }, + "today_install": { "type": "integer" }, - "is_pre_release": { - "$ref": "#/definitions/script_entity.EnablePreRelease" - }, - "meta,omitempty": { - "type": "string" + "total_install": { + "type": "integer" }, - "meta_json": { - "type": "object" + "type": { + "$ref": "#/definitions/script_entity.Type" }, - "script_id": { + "unwell": { "type": "integer" }, - "status": { + "updatetime": { "type": "integer" - }, - "version": { - "type": "string" } } }, - "script.CodeResponse": { - "type": "object", - "properties": { - "content": { - "type": "string" - } - } + "script.SelfScoreResponse": { + "type": "object" }, - "script.CreateFolderRequest": { + "script.StateResponse": { "type": "object", "properties": { - "description": { - "type": "string" + "favorite_count": { + "description": "收藏人数", + "type": "integer" }, - "name": { - "type": "string" + "favorite_ids": { + "description": "收藏夹", + "type": "array", + "items": { + "type": "integer" + } }, - "private": { - "description": "1私密 2公开", - "type": "integer" - } - } - }, - "script.CreateFolderResponse": { - "type": "object", - "properties": { - "id": { + "issue_count": { + "description": "Issue数量", "type": "integer" - } - } - }, - "script.CreateGroupInviteCodeRequest": { - "type": "object", - "properties": { - "audit": { - "type": "boolean" }, - "count": { + "report_count": { + "description": "未解决举报数量", "type": "integer" }, - "days": { - "description": "0 为永久", + "watch": { + "$ref": "#/definitions/script_entity.ScriptWatchLevel" + }, + "watch_count": { + "description": "关注人数", "type": "integer" } } }, - "script.CreateGroupInviteCodeResponse": { - "type": "object", - "properties": { - "code": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "script.CreateGroupRequest": { + "script.UnfavoriteScriptRequest": { "type": "object", "properties": { - "description": { - "type": "string" - }, - "name": { - "type": "string" + "script_id": { + "type": "integer" } } }, - "script.CreateGroupResponse": { + "script.UnfavoriteScriptResponse": { "type": "object" }, - "script.CreateInviteCodeRequest": { + "script.UpdateAccessRequest": { "type": "object", "properties": { - "audit": { - "type": "boolean" - }, - "count": { - "type": "integer" - }, - "days": { + "expiretime,default=0": { "description": "0 为永久", "type": "integer" + }, + "role": { + "description": "访问权限 guest=访客 manager=管理员", + "$ref": "#/definitions/script_entity.AccessRole" } } }, - "script.CreateInviteCodeResponse": { - "type": "object", - "properties": { - "code": { - "type": "array", - "items": { - "type": "string" - } - } - } + "script.UpdateAccessResponse": { + "type": "object" }, - "script.CreateRequest": { + "script.UpdateCodeRequest": { "type": "object", "properties": { - "category": { + "category_id": { "description": "分类ID", "type": "integer" }, @@ -11051,15 +12441,8 @@ var doc = `{ "definition": { "type": "string" }, - "description": { - "type": "string" - }, - "name": { - "type": "string" - }, - "public": { - "description": "公开类型:1 公开 2 半公开 3 私有", - "$ref": "#/definitions/script_entity.Public" + "is_pre_release": { + "$ref": "#/definitions/script_entity.EnablePreRelease" }, "tags": { "description": "标签,只有脚本类型为库时才有意义", @@ -11068,76 +12451,30 @@ var doc = `{ "type": "string" } }, - "type": { - "description": "脚本类型:1 用户脚本 2 订阅脚本(不支持) 3 脚本引用库", - "$ref": "#/definitions/script_entity.Type" - }, - "unwell": { - "description": "不适内容: 1 不适 2 适用", - "$ref": "#/definitions/script_entity.UnwellContent" - }, "version": { + "description": "Name string ` + "`" + `form:\"name\" binding:\"max=128\" label:\"库的名字\"` + "`" + `\nDescription string ` + "`" + `form:\"description\" binding:\"max=102400\" label:\"库的描述\"` + "`" + `", "type": "string" } } }, - "script.CreateResponse": { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - "script.DelScoreRequest": { - "type": "object" - }, - "script.DelScoreResponse": { - "type": "object" - }, - "script.DeleteAccessRequest": { - "type": "object" - }, - "script.DeleteAccessResponse": { - "type": "object" - }, - "script.DeleteCodeRequest": { - "type": "object" - }, - "script.DeleteCodeResponse": { - "type": "object" - }, - "script.DeleteFolderRequest": { - "type": "object" - }, - "script.DeleteFolderResponse": { - "type": "object" - }, - "script.DeleteGroupRequest": { - "type": "object" - }, - "script.DeleteGroupResponse": { - "type": "object" - }, - "script.DeleteInviteCodeRequest": { - "type": "object" - }, - "script.DeleteInviteCodeResponse": { + "script.UpdateCodeResponse": { "type": "object" }, - "script.DeleteRequest": { + "script.UpdateCodeSettingRequest": { "type": "object", "properties": { - "reason": { - "description": "删除原因(可选)", + "changelog": { "type": "string" + }, + "is_pre_release": { + "$ref": "#/definitions/script_entity.EnablePreRelease" } } }, - "script.DeleteResponse": { + "script.UpdateCodeSettingResponse": { "type": "object" }, - "script.EditFolderRequest": { + "script.UpdateGroupRequest": { "type": "object", "properties": { "description": { @@ -11145,88 +12482,77 @@ var doc = `{ }, "name": { "type": "string" - }, - "private": { - "description": "1私密 2公开", - "type": "integer" } } }, - "script.EditFolderResponse": { - "type": "object" - }, - "script.FavoriteFolderDetailResponse": { + "script.UpdateGroupResponse": { "type": "object" }, - "script.FavoriteFolderItem": { + "script.UpdateLibInfoRequest": { "type": "object", "properties": { - "count": { - "description": "收藏夹中脚本数量", - "type": "integer" - }, "description": { - "description": "收藏夹描述", "type": "string" }, - "id": { - "type": "integer" - }, "name": { "type": "string" - }, - "private": { - "description": "收藏夹类型 1私密 2公开", - "type": "integer" - }, - "updatetime": { - "description": "收藏夹更新时间", + } + } + }, + "script.UpdateLibInfoResponse": { + "type": "object" + }, + "script.UpdateMemberRequest": { + "type": "object", + "properties": { + "expiretime": { "type": "integer" } } }, - "script.FavoriteFolderListResponse": { + "script.UpdateMemberResponse": { + "type": "object" + }, + "script.UpdateScriptGrayRequest": { "type": "object", "properties": { - "list": { + "enable_pre_release": { + "$ref": "#/definitions/script_entity.EnablePreRelease" + }, + "gray_controls": { "type": "array", "items": { - "type": "object", - "$ref": "#/definitions/script.FavoriteFolderItem" + "$ref": "#/definitions/script_entity.GrayControl" } - }, - "total": { - "type": "integer" } } }, - "script.FavoriteScriptListResponse": { + "script.UpdateScriptGrayResponse": { + "type": "object" + }, + "script.UpdateScriptPublicRequest": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/script.Script" - } - }, - "total": { - "type": "integer" + "public": { + "$ref": "#/definitions/script_entity.Public" } } }, - "script.FavoriteScriptRequest": { + "script.UpdateScriptPublicResponse": { + "type": "object" + }, + "script.UpdateScriptUnwellRequest": { "type": "object", "properties": { - "script_id": { - "type": "integer" + "unwell": { + "$ref": "#/definitions/script_entity.UnwellContent" } } }, - "script.FavoriteScriptResponse": { + "script.UpdateScriptUnwellResponse": { "type": "object" }, - "script.GetSettingResponse": { + "script.UpdateSettingRequest": { "type": "object", "properties": { "content_url": { @@ -11235,14 +12561,11 @@ var doc = `{ "definition_url": { "type": "string" }, - "enable_pre_release": { - "$ref": "#/definitions/script_entity.EnablePreRelease" + "description": { + "type": "string" }, - "gray_controls": { - "type": "array", - "items": { - "$ref": "#/definitions/script_entity.GrayControl" - } + "name": { + "type": "string" }, "sync_mode": { "$ref": "#/definitions/script_entity.SyncMode" @@ -11252,55 +12575,40 @@ var doc = `{ } } }, - "script.Group": { + "script.UpdateSettingResponse": { + "type": "object" + }, + "script.UpdateSyncSettingRequest": { "type": "object", "properties": { - "createtime": { - "type": "integer" - }, - "description": { + "content_url": { "type": "string" }, - "id": { - "type": "integer" - }, - "member": { - "type": "array", - "items": { - "$ref": "#/definitions/script.GroupMember" - } + "definition_url": { + "type": "string" }, - "member_count": { - "type": "integer" + "sync_mode": { + "$ref": "#/definitions/script_entity.SyncMode" }, - "name": { + "sync_url": { "type": "string" } } }, - "script.GroupInviteCodeListResponse": { - "type": "object", - "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/script.InviteCode" - } - }, - "total": { - "type": "integer" - } - } + "script.UpdateSyncSettingResponse": { + "type": "object" }, - "script.GroupListResponse": { + "script.VersionCodeResponse": { + "type": "object" + }, + "script.VersionListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/script.Group" + "$ref": "#/definitions/script.Code" } }, "total": { @@ -11308,840 +12616,689 @@ var doc = `{ } } }, - "script.GroupMember": { + "script.VersionStatResponse": { "type": "object", "properties": { - "avatar": { - "type": "string" - }, - "createtime": { - "type": "integer" - }, - "expiretime": { - "type": "integer" - }, - "id": { + "pre_release_num": { + "description": "预发布版本数量", "type": "integer" }, - "invite_status": { - "$ref": "#/definitions/script_entity.AccessInviteStatus" - }, - "is_expire": { - "type": "boolean" - }, - "user_id": { + "release_num": { + "description": "正式版本数量", "type": "integer" - }, - "username": { - "type": "string" } } }, - "script.GroupMemberListResponse": { + "script.WatchRequest": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/script.GroupMember" - } - }, - "total": { - "type": "integer" + "watch": { + "$ref": "#/definitions/script_entity.ScriptWatchLevel" } } }, - "script.InfoResponse": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "role": { - "$ref": "#/definitions/script_entity.AccessRole" - }, - "sri,omitempty": { - "description": "如果是库的话,会返回sha512 sri", - "type": "string" - } - } + "script.WatchResponse": { + "type": "object" }, - "script.InviteCode": { + "script.WebhookRequest": { "type": "object", "properties": { - "code": { - "description": "邀请码", + "UA": { "type": "string" }, - "createtime": { - "type": "integer" - }, - "expiretime": { - "description": "到期时间", - "type": "integer" - }, - "id": { - "type": "integer" - }, - "invite_status": { - "$ref": "#/definitions/script_entity.InviteStatus" - }, - "is_audit": { - "description": "是否需要审核", - "type": "boolean" - }, - "used": { - "description": "使用用户", - "type": "integer" - }, - "username": { - "description": "使用用户名", + "XHubSignature256": { "type": "string" } } }, - "script.InviteCodeInfoAccess": { + "script.WebhookResponse": { "type": "object", "properties": { - "role": { - "description": "访问权限 guest=访客 manager=管理员", - "$ref": "#/definitions/script_entity.AccessRole" + "error_messages": { + "type": "object", + "additionalProperties": { + "type": "string" + } } } }, - "script.InviteCodeInfoGroup": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "name": { - "type": "string" - } - } + "script_entity.AccessInviteStatus": { + "description": "AccessInviteStatus enum type:\n- AccessInviteStatusAccept: 1\n- AccessInviteStatusReject: 2\n- AccessInviteStatusPending: 3", + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] }, - "script.InviteCodeInfoResponse": { - "type": "object", - "properties": { - "access,omitempty": { - "description": "如果type=1, 则返回权限信息", - "$ref": "#/definitions/script.InviteCodeInfoAccess" - }, - "code_type": { - "description": "邀请码类型 1=邀请码 2=邀请链接", - "$ref": "#/definitions/script_entity.InviteCodeType" - }, - "group,omitempty": { - "description": "如果type=2, 则返回群组信息", - "$ref": "#/definitions/script.InviteCodeInfoGroup" - }, - "invite_status": { - "description": "使用状态", - "$ref": "#/definitions/script_entity.InviteStatus" - }, - "is_audit": { - "description": "是否需要审核 邀请码类型为邀请链接时,该字段固定为false", - "type": "boolean" - }, - "script": { - "$ref": "#/definitions/script.Script" - }, - "type": { - "description": "邀请类型 1=权限邀请码 2=群组邀请码", - "$ref": "#/definitions/script_entity.InviteType" - } - } + "script_entity.AccessRole": { + "description": "AccessRole enum type:\n- AccessRoleGuest: guest\n- AccessRoleManager: manager\n- AccessRoleOwner: owner", + "type": "string", + "enum": [ + "guest", + "manager", + "owner" + ] }, - "script.InviteCodeListResponse": { + "script_entity.AccessType": { + "description": "AccessType enum type:\n- AccessTypeUser: 1\n- AccessTypeGroup: 2", + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "script_entity.Control": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/script.InviteCode" - } + "params": { + "$ref": "#/definitions/script_entity.GrayControlParams" }, - "total": { - "type": "integer" + "type": { + "$ref": "#/definitions/script_entity.GrayControlType" } } }, - "script.LastScoreResponse": { + "script_entity.EnablePreRelease": { + "description": "EnablePreRelease enum type:\n- EnablePreReleaseScript: 1\n- DisablePreReleaseScript: 2", + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "script_entity.GrayControl": { "type": "object", "properties": { - "list": { + "controls": { "type": "array", "items": { - "type": "object", - "$ref": "#/definitions/script.Script" + "$ref": "#/definitions/script_entity.Control" } }, - "total": { - "type": "integer" + "target_version": { + "type": "string" } } }, - "script.ListResponse": { + "script_entity.GrayControlParams": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/script.Script" - } + "cookie_regex": { + "type": "string" }, - "total": { + "weight": { "type": "integer" + }, + "weight_day": { + "type": "number" } } }, - "script.MigrateEsRequest": { - "type": "object" + "script_entity.GrayControlType": { + "description": "GrayControlType enum type:\n- GrayControlTypeWeight: weight\n- GrayControlTypeCookie: cookie\n- GrayControlTypePreRelease: pre-release", + "type": "string", + "enum": [ + "weight", + "cookie", + "pre-release" + ] }, - "script.MigrateEsResponse": { - "type": "object" + "script_entity.InviteCodeType": { + "description": "InviteCodeType enum type:\n- InviteCodeTypeCode: 1\n- InviteCodeTypeLink: 2", + "type": "integer", + "enum": [ + 1, + 2 + ] }, - "script.PutScoreRequest": { + "script_entity.InviteStatus": { + "description": "InviteStatus enum type:\n- InviteStatusUnused: 1\n- InviteStatusUsed: 2\n- InviteStatusExpired: 3\n- InviteStatusPending: 4\n- InviteStatusReject: 5", + "type": "integer", + "enum": [ + 1, + 2, + 3, + 4, + 5 + ] + }, + "script_entity.InviteType": { + "description": "InviteType enum type:\n- InviteTypeAccess: 1\n- InviteTypeGroup: 2", + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "script_entity.Public": { + "description": "Public enum type:\n- PublicScript: 1\n- UnPublicScript: 2\n- PrivateScript: 3", + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + }, + "script_entity.ScriptCategoryType": { + "description": "ScriptCategoryType enum type:\n- ScriptCategoryTypeCategory: 1\n- ScriptCategoryTypeTag: 2", + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "script_entity.ScriptWatchLevel": { + "description": "ScriptWatchLevel enum type:\n- ScriptWatchLevelNone: 0\n- ScriptWatchLevelVersion: 1\n- ScriptWatchLevelIssue: 2\n- ScriptWatchLevelIssueComment: 3", + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ] + }, + "script_entity.SyncMode": { + "description": "SyncMode enum type:\n- SyncModeAuto: 1\n- SyncModeManual: 2", + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "script_entity.Type": { + "description": "Type enum type:\n- UserscriptType: 1\n- SubscribeType: 2\n- LibraryType: 3", + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + }, + "script_entity.UnwellContent": { + "description": "UnwellContent enum type:\n- Unwell: 1\n- Well: 2", + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "similarity.AddIntegrityWhitelistRequest": { "type": "object", "properties": { - "message": { + "reason": { "type": "string" }, - "score": { + "script_id": { "type": "integer" } } }, - "script.PutScoreResponse": { + "similarity.AddIntegrityWhitelistResponse": { + "type": "object" + }, + "similarity.AddPairWhitelistRequest": { "type": "object", "properties": { - "id": { - "type": "integer" + "reason": { + "type": "string" } } }, - "script.RecordVisitRequest": { - "type": "object" - }, - "script.RecordVisitResponse": { - "type": "object" - }, - "script.RemoveMemberRequest": { + "similarity.AddPairWhitelistResponse": { "type": "object" }, - "script.RemoveMemberResponse": { - "type": "object" - }, - "script.ReplyScoreRequest": { + "similarity.AdminActions": { "type": "object", "properties": { - "commentID": { - "type": "integer" - }, - "message": { - "type": "string" + "can_whitelist": { + "type": "boolean" } } }, - "script.ReplyScoreResponse": { - "type": "object" - }, - "script.Score": { + "similarity.GetEvidencePairResponse": { "type": "object", "properties": { - "author_message": { - "type": "string" - }, - "author_message_createtime": { - "type": "integer" - }, - "createtime": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "score": { - "type": "integer" - }, - "script_id": { - "type": "integer" - }, - "state": { - "type": "integer" - }, - "updatetime": { - "type": "integer" + "detail": { + "$ref": "#/definitions/similarity.PairDetail" } } }, - "script.ScoreListResponse": { + "similarity.GetIntegrityReviewResponse": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/script.Score" - } - }, - "total": { - "type": "integer" + "detail": { + "$ref": "#/definitions/similarity.IntegrityReviewDetail" } } }, - "script.ScoreStateResponse": { + "similarity.GetPairDetailResponse": { "type": "object", "properties": { - "score_group": { - "description": "每个评分的数量", - "type": "object", - "additionalProperties": { - "type": "integer" - } - }, - "score_user_count": { - "description": "评分人数", - "type": "integer" + "detail": { + "$ref": "#/definitions/similarity.PairDetail" } } }, - "script.Script": { + "similarity.IntegrityHitSignal": { "type": "object", "properties": { - "archive": { - "type": "integer" - }, - "category": { - "$ref": "#/definitions/script.CategoryListItem" - }, - "createtime": { - "type": "integer" - }, - "danger": { - "type": "integer" - }, - "description": { - "type": "string" - }, - "enable_pre_release": { - "$ref": "#/definitions/script_entity.EnablePreRelease" - }, - "id": { - "type": "integer" - }, "name": { "type": "string" }, - "post_id": { - "type": "integer" - }, - "public": { - "type": "integer" - }, - "score": { - "type": "integer" - }, - "score_num": { - "type": "integer" - }, - "script": { - "$ref": "#/definitions/script.Code" + "threshold": { + "type": "number" }, - "status": { - "type": "integer" + "value": { + "type": "number" + } + } + }, + "similarity.IntegrityReviewDetail": { + "type": "object", + "properties": { + "code": { + "type": "string" }, - "tags": { + "hit_signals": { "type": "array", "items": { - "$ref": "#/definitions/script.CategoryListItem" + "$ref": "#/definitions/similarity.IntegrityHitSignal" } }, - "today_install": { - "type": "integer" + "review_note,omitempty": { + "type": "string" }, - "total_install": { + "reviewed_at,omitempty": { "type": "integer" }, - "type": { - "$ref": "#/definitions/script_entity.Type" - }, - "unwell": { + "reviewed_by,omitempty": { "type": "integer" }, - "updatetime": { - "type": "integer" + "sub_scores": { + "$ref": "#/definitions/similarity.IntegritySubScores" } } }, - "script.SelfScoreResponse": { - "type": "object" - }, - "script.StateResponse": { + "similarity.IntegrityReviewItem": { "type": "object", "properties": { - "favorite_count": { - "description": "收藏人数", + "createtime": { "type": "integer" }, - "favorite_ids": { - "description": "收藏夹", - "type": "array", - "items": { - "type": "integer" - } - }, - "issue_count": { - "description": "Issue数量", + "id": { "type": "integer" }, - "report_count": { - "description": "未解决举报数量", - "type": "integer" + "score": { + "type": "number" }, - "watch": { - "$ref": "#/definitions/script_entity.ScriptWatchLevel" + "script": { + "$ref": "#/definitions/similarity.ScriptBrief" }, - "watch_count": { - "description": "关注人数", + "script_code_id": { "type": "integer" - } - } - }, - "script.UnfavoriteScriptRequest": { - "type": "object", - "properties": { - "script_id": { + }, + "status": { "type": "integer" } } }, - "script.UnfavoriteScriptResponse": { - "type": "object" - }, - "script.UpdateAccessRequest": { + "similarity.IntegritySubScores": { "type": "object", "properties": { - "expiretime,default=0": { - "description": "0 为永久", - "type": "integer" + "cat_a": { + "type": "number" }, - "role": { - "description": "访问权限 guest=访客 manager=管理员", - "$ref": "#/definitions/script_entity.AccessRole" + "cat_b": { + "type": "number" + }, + "cat_c": { + "type": "number" + }, + "cat_d": { + "type": "number" } } }, - "script.UpdateAccessResponse": { - "type": "object" - }, - "script.UpdateCodeRequest": { + "similarity.IntegrityWhitelistItem": { "type": "object", "properties": { - "category_id": { - "description": "分类ID", + "added_by": { "type": "integer" }, - "changelog": { + "added_by_name": { "type": "string" }, - "code": { - "type": "string" + "createtime": { + "type": "integer" }, - "content": { - "type": "string" + "id": { + "type": "integer" }, - "definition": { + "reason": { "type": "string" }, - "is_pre_release": { - "$ref": "#/definitions/script_entity.EnablePreRelease" - }, - "tags": { - "description": "标签,只有脚本类型为库时才有意义", - "type": "array", - "items": { - "type": "string" - } - }, - "version": { - "description": "Name string ` + "`" + `form:\"name\" binding:\"max=128\" label:\"库的名字\"` + "`" + `\nDescription string ` + "`" + `form:\"description\" binding:\"max=102400\" label:\"库的描述\"` + "`" + `", - "type": "string" + "script": { + "$ref": "#/definitions/similarity.ScriptBrief" } } }, - "script.UpdateCodeResponse": { - "type": "object" - }, - "script.UpdateCodeSettingRequest": { + "similarity.ListIntegrityReviewsResponse": { "type": "object", "properties": { - "changelog": { - "type": "string" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/similarity.IntegrityReviewItem" + } }, - "is_pre_release": { - "$ref": "#/definitions/script_entity.EnablePreRelease" + "total": { + "type": "integer" } } }, - "script.UpdateCodeSettingResponse": { - "type": "object" - }, - "script.UpdateGroupRequest": { + "similarity.ListIntegrityWhitelistResponse": { "type": "object", "properties": { - "description": { - "type": "string" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/similarity.IntegrityWhitelistItem" + } }, - "name": { - "type": "string" + "total": { + "type": "integer" } } }, - "script.UpdateGroupResponse": { - "type": "object" - }, - "script.UpdateLibInfoRequest": { + "similarity.ListPairWhitelistResponse": { "type": "object", "properties": { - "description": { - "type": "string" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/similarity.PairWhitelistItem" + } }, - "name": { - "type": "string" - } - } - }, - "script.UpdateLibInfoResponse": { - "type": "object" - }, - "script.UpdateMemberRequest": { - "type": "object", - "properties": { - "expiretime": { + "total": { "type": "integer" } } }, - "script.UpdateMemberResponse": { - "type": "object" - }, - "script.UpdateScriptGrayRequest": { + "similarity.ListPairsResponse": { "type": "object", "properties": { - "enable_pre_release": { - "$ref": "#/definitions/script_entity.EnablePreRelease" - }, - "gray_controls": { + "list": { "type": "array", "items": { - "$ref": "#/definitions/script_entity.GrayControl" + "type": "object", + "$ref": "#/definitions/similarity.SimilarPairItem" } + }, + "total": { + "type": "integer" } } }, - "script.UpdateScriptGrayResponse": { - "type": "object" - }, - "script.UpdateScriptPublicRequest": { + "similarity.ListSuspectsResponse": { "type": "object", "properties": { - "public": { - "$ref": "#/definitions/script_entity.Public" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/similarity.SuspectScriptItem" + } + }, + "total": { + "type": "integer" } } }, - "script.UpdateScriptPublicResponse": { - "type": "object" - }, - "script.UpdateScriptUnwellRequest": { + "similarity.MatchSegment": { "type": "object", "properties": { - "unwell": { - "$ref": "#/definitions/script_entity.UnwellContent" + "a_end": { + "type": "integer" + }, + "a_start": { + "type": "integer" + }, + "b_end": { + "type": "integer" + }, + "b_start": { + "type": "integer" } } }, - "script.UpdateScriptUnwellResponse": { - "type": "object" - }, - "script.UpdateSettingRequest": { + "similarity.PairDetail": { "type": "object", "properties": { - "content_url": { - "type": "string" + "a_fp_count": { + "type": "integer" }, - "definition_url": { - "type": "string" + "admin_actions,omitempty": { + "$ref": "#/definitions/similarity.AdminActions" }, - "description": { + "b_fp_count": { + "type": "integer" + }, + "code_a": { "type": "string" }, - "name": { + "code_b": { "type": "string" }, - "sync_mode": { - "$ref": "#/definitions/script_entity.SyncMode" + "common_count": { + "type": "integer" }, - "sync_url": { - "type": "string" - } - } - }, - "script.UpdateSettingResponse": { - "type": "object" - }, - "script.UpdateSyncSettingRequest": { - "type": "object", - "properties": { - "content_url": { - "type": "string" + "detected_at": { + "type": "integer" }, - "definition_url": { + "earlier_side": { "type": "string" }, - "sync_mode": { - "$ref": "#/definitions/script_entity.SyncMode" + "id": { + "type": "integer" }, - "sync_url": { - "type": "string" - } - } - }, - "script.UpdateSyncSettingResponse": { - "type": "object" - }, - "script.VersionCodeResponse": { - "type": "object" - }, - "script.VersionListResponse": { - "type": "object", - "properties": { - "list": { + "jaccard": { + "type": "number" + }, + "match_segments": { "type": "array", "items": { - "type": "object", - "$ref": "#/definitions/script.Code" + "$ref": "#/definitions/similarity.MatchSegment" } }, - "total": { + "review_note,omitempty": { + "type": "string" + }, + "script_a": { + "$ref": "#/definitions/similarity.ScriptFullInfo" + }, + "script_b": { + "$ref": "#/definitions/similarity.ScriptFullInfo" + }, + "status,omitempty": { + "description": "Admin-only fields (omitted on evidence page)", "type": "integer" } } }, - "script.VersionStatResponse": { + "similarity.PairWhitelistItem": { "type": "object", "properties": { - "pre_release_num": { - "description": "预发布版本数量", + "added_by": { + "type": "integer" + }, + "added_by_name": { + "type": "string" + }, + "createtime": { "type": "integer" }, - "release_num": { - "description": "正式版本数量", + "id": { "type": "integer" + }, + "reason": { + "type": "string" + }, + "script_a": { + "$ref": "#/definitions/similarity.ScriptBrief" + }, + "script_b": { + "$ref": "#/definitions/similarity.ScriptBrief" } } }, - "script.WatchRequest": { + "similarity.RemoveIntegrityWhitelistRequest": { + "type": "object" + }, + "similarity.RemoveIntegrityWhitelistResponse": { + "type": "object" + }, + "similarity.RemovePairWhitelistRequest": { + "type": "object" + }, + "similarity.RemovePairWhitelistResponse": { + "type": "object" + }, + "similarity.ResolveIntegrityReviewRequest": { "type": "object", "properties": { - "watch": { - "$ref": "#/definitions/script_entity.ScriptWatchLevel" + "note": { + "type": "string" + }, + "status": { + "type": "integer" } } }, - "script.WatchResponse": { + "similarity.ResolveIntegrityReviewResponse": { "type": "object" }, - "script.WebhookRequest": { + "similarity.ScriptBrief": { "type": "object", "properties": { - "UA": { + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "name": { "type": "string" }, - "XHubSignature256": { + "public": { + "description": "1=public 2=unlisted 3=private", + "type": "integer" + }, + "user_id": { + "type": "integer" + }, + "username": { "type": "string" } } }, - "script.WebhookResponse": { + "similarity.ScriptFullInfo": { "type": "object", "properties": { - "error_messages": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "code_created_at": { + "type": "integer" + }, + "script_code_id": { + "type": "integer" + }, + "version": { + "type": "string" } } }, - "script_entity.AccessInviteStatus": { - "description": "AccessInviteStatus enum type:\n- AccessInviteStatusAccept: 1\n- AccessInviteStatusReject: 2\n- AccessInviteStatusPending: 3", - "type": "integer", - "enum": [ - 1, - 2, - 3 - ] - }, - "script_entity.AccessRole": { - "description": "AccessRole enum type:\n- AccessRoleGuest: guest\n- AccessRoleManager: manager\n- AccessRoleOwner: owner", - "type": "string", - "enum": [ - "guest", - "manager", - "owner" - ] - }, - "script_entity.AccessType": { - "description": "AccessType enum type:\n- AccessTypeUser: 1\n- AccessTypeGroup: 2", - "type": "integer", - "enum": [ - 1, - 2 - ] - }, - "script_entity.Control": { + "similarity.SimilarPairItem": { "type": "object", "properties": { - "params": { - "$ref": "#/definitions/script_entity.GrayControlParams" + "common_count": { + "type": "integer" }, - "type": { - "$ref": "#/definitions/script_entity.GrayControlType" + "detected_at": { + "type": "integer" + }, + "earlier_side": { + "description": "\"A\" | \"B\" | \"same\"", + "type": "string" + }, + "id": { + "type": "integer" + }, + "integrity_score,omitempty": { + "description": "§10.12, 0 if not available", + "type": "number" + }, + "jaccard": { + "type": "number" + }, + "script_a": { + "$ref": "#/definitions/similarity.ScriptBrief" + }, + "script_b": { + "$ref": "#/definitions/similarity.ScriptBrief" + }, + "status": { + "type": "integer" } } }, - "script_entity.EnablePreRelease": { - "description": "EnablePreRelease enum type:\n- EnablePreReleaseScript: 1\n- DisablePreReleaseScript: 2", - "type": "integer", - "enum": [ - 1, - 2 - ] - }, - "script_entity.GrayControl": { + "similarity.SuspectScriptItem": { "type": "object", "properties": { - "controls": { + "coverage": { + "type": "number" + }, + "detected_at": { + "type": "integer" + }, + "integrity_score,omitempty": { + "type": "number" + }, + "max_jaccard": { + "type": "number" + }, + "pair_count": { + "type": "integer" + }, + "script": { + "$ref": "#/definitions/similarity.ScriptBrief" + }, + "top_sources": { "type": "array", "items": { - "$ref": "#/definitions/script_entity.Control" + "$ref": "#/definitions/similarity.TopSource" } - }, - "target_version": { - "type": "string" } } }, - "script_entity.GrayControlParams": { + "similarity.TopSource": { "type": "object", "properties": { - "cookie_regex": { - "type": "string" + "contribution_pct": { + "type": "number" }, - "weight": { + "jaccard": { + "type": "number" + }, + "script_id": { "type": "integer" }, - "weight_day": { - "type": "number" + "script_name": { + "type": "string" } } }, - "script_entity.GrayControlType": { - "description": "GrayControlType enum type:\n- GrayControlTypeWeight: weight\n- GrayControlTypeCookie: cookie\n- GrayControlTypePreRelease: pre-release", - "type": "string", - "enum": [ - "weight", - "cookie", - "pre-release" - ] - }, - "script_entity.InviteCodeType": { - "description": "InviteCodeType enum type:\n- InviteCodeTypeCode: 1\n- InviteCodeTypeLink: 2", - "type": "integer", - "enum": [ - 1, - 2 - ] - }, - "script_entity.InviteStatus": { - "description": "InviteStatus enum type:\n- InviteStatusUnused: 1\n- InviteStatusUsed: 2\n- InviteStatusExpired: 3\n- InviteStatusPending: 4\n- InviteStatusReject: 5", - "type": "integer", - "enum": [ - 1, - 2, - 3, - 4, - 5 - ] - }, - "script_entity.InviteType": { - "description": "InviteType enum type:\n- InviteTypeAccess: 1\n- InviteTypeGroup: 2", - "type": "integer", - "enum": [ - 1, - 2 - ] - }, - "script_entity.Public": { - "description": "Public enum type:\n- PublicScript: 1\n- UnPublicScript: 2\n- PrivateScript: 3", - "type": "integer", - "enum": [ - 1, - 2, - 3 - ] - }, - "script_entity.ScriptCategoryType": { - "description": "ScriptCategoryType enum type:\n- ScriptCategoryTypeCategory: 1\n- ScriptCategoryTypeTag: 2", - "type": "integer", - "enum": [ - 1, - 2 - ] - }, - "script_entity.ScriptWatchLevel": { - "description": "ScriptWatchLevel enum type:\n- ScriptWatchLevelNone: 0\n- ScriptWatchLevelVersion: 1\n- ScriptWatchLevelIssue: 2\n- ScriptWatchLevelIssueComment: 3", - "type": "integer", - "enum": [ - 0, - 1, - 2, - 3 - ] - }, - "script_entity.SyncMode": { - "description": "SyncMode enum type:\n- SyncModeAuto: 1\n- SyncModeManual: 2", - "type": "integer", - "enum": [ - 1, - 2 - ] - }, - "script_entity.Type": { - "description": "Type enum type:\n- UserscriptType: 1\n- SubscribeType: 2\n- LibraryType: 3", - "type": "integer", - "enum": [ - 1, - 2, - 3 - ] - }, - "script_entity.UnwellContent": { - "description": "UnwellContent enum type:\n- Unwell: 1\n- Well: 2", - "type": "integer", - "enum": [ - 1, - 2 - ] - }, "statistics.Chart": { "type": "object", "properties": { @@ -12360,6 +13517,13 @@ var doc = `{ "$ref": "#/definitions/user.BadgeItem" } }, + "ban_expire_at,omitempty": { + "type": "integer" + }, + "ban_reason,omitempty": { + "description": "以下字段仅管理员可见", + "type": "string" + }, "description": { "description": "个人简介", "type": "string" @@ -12392,6 +13556,15 @@ var doc = `{ "description": "位置", "type": "string" }, + "register_email,omitempty": { + "type": "string" + }, + "register_ip,omitempty": { + "type": "string" + }, + "register_ip_location,omitempty": { + "type": "string" + }, "website": { "description": "个人网站", "type": "string" @@ -12486,6 +13659,23 @@ var doc = `{ } } }, + "user.SendEmailCodeRequest": { + "type": "object", + "properties": { + "email": { + "description": "邮箱", + "type": "string" + } + } + }, + "user.SendEmailCodeResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, "user.UpdateConfigRequest": { "type": "object", "properties": { @@ -12520,6 +13710,10 @@ var doc = `{ "description": "邮箱", "type": "string" }, + "email_code": { + "description": "邮箱验证码", + "type": "string" + }, "location": { "description": "位置", "type": "string" @@ -12613,7 +13807,7 @@ type s struct{} func (s *s) ReadDoc() string { sInfo := SwaggerInfo - sInfo.Description = strings.ReplaceAll(sInfo.Description, "\n", "\\n") + sInfo.Description = strings.Replace(sInfo.Description, "\n", "\\n", -1) t, err := template.New("swagger_info").Funcs(template.FuncMap{ "marshal": func(v interface{}) string { @@ -12622,10 +13816,10 @@ func (s *s) ReadDoc() string { }, "escape": func(v interface{}) string { // escape tabs - str := strings.ReplaceAll(v.(string), "\t", "\\t") + str := strings.Replace(v.(string), "\t", "\\t", -1) // replace " with \", and if that results in \\", replace that with \\\" - str = strings.ReplaceAll(str, "\"", "\\\"") - return strings.ReplaceAll(str, "\\\\\"", "\\\\\\\"") + str = strings.Replace(str, "\"", "\\\"", -1) + return strings.Replace(str, "\\\\\"", "\\\\\\\"", -1) }, }).Parse(doc) if err != nil { diff --git a/docs/swagger.json b/docs/swagger.json index 6863f36..9b23485 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1118,9 +1118,8 @@ } } }, - "/admin/system-configs": { + "/admin/similarity/integrity/reviews": { "get": { - "description": "获取系统配置", "consumes": [ "application/json" ], @@ -1128,13 +1127,13 @@ "application/json" ], "tags": [ - "admin" + "similarity" ], - "summary": "获取系统配置", "parameters": [ { - "type": "string", - "name": "prefix", + "type": "integer", + "description": "0=pending 1=ok 2=violated", + "name": "status", "in": "query" } ], @@ -1147,7 +1146,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/admin.GetSystemConfigsResponse" + "$ref": "#/definitions/similarity.ListIntegrityReviewsResponse" }, "msg": { "type": "string" @@ -1162,9 +1161,10 @@ } } } - }, - "put": { - "description": "更新系统配置", + } + }, + "/admin/similarity/integrity/reviews/{id}": { + "get": { "consumes": [ "application/json" ], @@ -1172,16 +1172,14 @@ "application/json" ], "tags": [ - "admin" + "similarity" ], - "summary": "更新系统配置", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/admin.UpdateSystemConfigsRequest" - } + "type": "integer", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -1193,7 +1191,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/admin.UpdateSystemConfigsResponse" + "$ref": "#/definitions/similarity.GetIntegrityReviewResponse" }, "msg": { "type": "string" @@ -1210,9 +1208,8 @@ } } }, - "/admin/users": { - "get": { - "description": "管理员获取用户列表", + "/admin/similarity/integrity/reviews/{id}/resolve": { + "post": { "consumes": [ "application/json" ], @@ -1220,14 +1217,21 @@ "application/json" ], "tags": [ - "admin" + "similarity" ], - "summary": "管理员获取用户列表", "parameters": [ { - "type": "string", - "name": "keyword", - "in": "query" + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/similarity.ResolveIntegrityReviewRequest" + } } ], "responses": { @@ -1239,7 +1243,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/admin.ListUsersResponse" + "$ref": "#/definitions/similarity.ResolveIntegrityReviewResponse" }, "msg": { "type": "string" @@ -1256,9 +1260,8 @@ } } }, - "/admin/users/{id}/admin-level": { - "put": { - "description": "更新用户管理员等级", + "/admin/similarity/integrity/whitelist": { + "get": { "consumes": [ "application/json" ], @@ -1266,23 +1269,7 @@ "application/json" ], "tags": [ - "admin" - ], - "summary": "更新用户管理员等级", - "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/admin.UpdateUserAdminLevelRequest" - } - } + "similarity" ], "responses": { "200": { @@ -1293,7 +1280,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/admin.UpdateUserAdminLevelResponse" + "$ref": "#/definitions/similarity.ListIntegrityWhitelistResponse" }, "msg": { "type": "string" @@ -1308,11 +1295,8 @@ } } } - } - }, - "/admin/users/{id}/status": { - "put": { - "description": "更新用户状态(封禁/解封)", + }, + "post": { "consumes": [ "application/json" ], @@ -1320,21 +1304,14 @@ "application/json" ], "tags": [ - "admin" + "similarity" ], - "summary": "更新用户状态(封禁/解封)", "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/admin.UpdateUserStatusRequest" + "$ref": "#/definitions/similarity.AddIntegrityWhitelistRequest" } } ], @@ -1347,7 +1324,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/admin.UpdateUserStatusResponse" + "$ref": "#/definitions/similarity.AddIntegrityWhitelistResponse" }, "msg": { "type": "string" @@ -1364,9 +1341,8 @@ } } }, - "/announcements": { - "get": { - "description": "公告列表", + "/admin/similarity/integrity/whitelist/{script_id}": { + "delete": { "consumes": [ "application/json" ], @@ -1374,14 +1350,21 @@ "application/json" ], "tags": [ - "announcement" + "similarity" ], - "summary": "公告列表", "parameters": [ { - "type": "string", - "name": "locale", - "in": "query" + "type": "integer", + "name": "script_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/similarity.RemoveIntegrityWhitelistRequest" + } } ], "responses": { @@ -1393,7 +1376,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/announcement.ListResponse" + "$ref": "#/definitions/similarity.RemoveIntegrityWhitelistResponse" }, "msg": { "type": "string" @@ -1410,9 +1393,8 @@ } } }, - "/announcements/latest": { + "/admin/similarity/pairs": { "get": { - "description": "最新重要公告", "consumes": [ "application/json" ], @@ -1420,13 +1402,23 @@ "application/json" ], "tags": [ - "announcement" + "similarity" ], - "summary": "最新重要公告", "parameters": [ { - "type": "string", - "name": "locale", + "type": "integer", + "description": "nil = any, 0/1/2 = filter", + "name": "status", + "in": "query" + }, + { + "type": "number", + "name": "min_jaccard", + "in": "query" + }, + { + "type": "integer", + "name": "script_id", "in": "query" } ], @@ -1439,7 +1431,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/announcement.LatestResponse" + "$ref": "#/definitions/similarity.ListPairsResponse" }, "msg": { "type": "string" @@ -1456,9 +1448,8 @@ } } }, - "/audit-logs": { + "/admin/similarity/pairs/{id}": { "get": { - "description": "全局管理日志(公开,仅返回管理员删除的脚本)", "consumes": [ "application/json" ], @@ -1466,9 +1457,16 @@ "application/json" ], "tags": [ - "audit/audit_log" + "similarity" + ], + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + } ], - "summary": "全局管理日志(公开,仅返回管理员删除的脚本)", "responses": { "200": { "description": "OK", @@ -1478,7 +1476,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/audit.ListResponse" + "$ref": "#/definitions/similarity.GetPairDetailResponse" }, "msg": { "type": "string" @@ -1495,9 +1493,8 @@ } } }, - "/auth/forgot-password": { + "/admin/similarity/pairs/{id}/whitelist": { "post": { - "description": "忘记密码", "consumes": [ "application/json" ], @@ -1505,15 +1502,20 @@ "application/json" ], "tags": [ - "auth" + "similarity" ], - "summary": "忘记密码", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.ForgotPasswordRequest" + "$ref": "#/definitions/similarity.AddPairWhitelistRequest" } } ], @@ -1526,7 +1528,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.ForgotPasswordResponse" + "$ref": "#/definitions/similarity.AddPairWhitelistResponse" }, "msg": { "type": "string" @@ -1541,11 +1543,8 @@ } } } - } - }, - "/auth/login": { - "post": { - "description": "登录(支持邮箱或用户名)", + }, + "delete": { "consumes": [ "application/json" ], @@ -1553,15 +1552,20 @@ "application/json" ], "tags": [ - "auth" + "similarity" ], - "summary": "登录(支持邮箱或用户名)", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.LoginRequest" + "$ref": "#/definitions/similarity.RemovePairWhitelistRequest" } } ], @@ -1574,7 +1578,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.LoginResponse" + "$ref": "#/definitions/similarity.RemovePairWhitelistResponse" }, "msg": { "type": "string" @@ -1591,9 +1595,8 @@ } } }, - "/auth/oidc/bindconfirm": { - "post": { - "description": "POST /auth/oidc/bindconfirm — 登录已有账号并绑定 OIDC", + "/admin/similarity/suspects": { + "get": { "consumes": [ "application/json" ], @@ -1601,16 +1604,23 @@ "application/json" ], "tags": [ - "auth/oidc_login" + "similarity" ], - "summary": "POST /auth/oidc/bindconfirm — 登录已有账号并绑定 OIDC", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/auth.OIDCBindConfirmRequest" - } + "type": "number", + "name": "min_jaccard", + "in": "query" + }, + { + "type": "number", + "name": "min_coverage", + "in": "query" + }, + { + "type": "integer", + "name": "status", + "in": "query" } ], "responses": { @@ -1622,7 +1632,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OIDCBindConfirmResponse" + "$ref": "#/definitions/similarity.ListSuspectsResponse" }, "msg": { "type": "string" @@ -1639,9 +1649,8 @@ } } }, - "/auth/oidc/bindinfo": { + "/admin/similarity/whitelist": { "get": { - "description": "GET /auth/oidc/bindinfo — 获取待绑定的 OIDC 信息", "consumes": [ "application/json" ], @@ -1649,15 +1658,7 @@ "application/json" ], "tags": [ - "auth/oidc_login" - ], - "summary": "GET /auth/oidc/bindinfo — 获取待绑定的 OIDC 信息", - "parameters": [ - { - "type": "string", - "name": "bind_token", - "in": "query" - } + "similarity" ], "responses": { "200": { @@ -1668,7 +1669,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OIDCBindInfoResponse" + "$ref": "#/definitions/similarity.ListPairWhitelistResponse" }, "msg": { "type": "string" @@ -1685,9 +1686,9 @@ } } }, - "/auth/oidc/providers": { + "/admin/system-configs": { "get": { - "description": "GET /auth/oidc/providers — 获取可用的 OIDC 登录提供商列表(公开接口)", + "description": "获取系统配置", "consumes": [ "application/json" ], @@ -1695,9 +1696,16 @@ "application/json" ], "tags": [ - "auth/oidc_login" + "admin" + ], + "summary": "获取系统配置", + "parameters": [ + { + "type": "string", + "name": "prefix", + "in": "query" + } ], - "summary": "GET /auth/oidc/providers — 获取可用的 OIDC 登录提供商列表(公开接口)", "responses": { "200": { "description": "OK", @@ -1707,7 +1715,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OIDCProvidersResponse" + "$ref": "#/definitions/admin.GetSystemConfigsResponse" }, "msg": { "type": "string" @@ -1722,11 +1730,9 @@ } } } - } - }, - "/auth/oidc/register": { - "post": { - "description": "POST /auth/oidc/register — 注册新账号并绑定 OIDC", + }, + "put": { + "description": "更新系统配置", "consumes": [ "application/json" ], @@ -1734,15 +1740,15 @@ "application/json" ], "tags": [ - "auth/oidc_login" + "admin" ], - "summary": "POST /auth/oidc/register — 注册新账号并绑定 OIDC", + "summary": "更新系统配置", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.OIDCRegisterAndBindRequest" + "$ref": "#/definitions/admin.UpdateSystemConfigsRequest" } } ], @@ -1755,7 +1761,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OIDCRegisterAndBindResponse" + "$ref": "#/definitions/admin.UpdateSystemConfigsResponse" }, "msg": { "type": "string" @@ -1772,9 +1778,9 @@ } } }, - "/auth/register": { - "post": { - "description": "邮箱注册", + "/admin/users": { + "get": { + "description": "管理员获取用户列表", "consumes": [ "application/json" ], @@ -1782,16 +1788,14 @@ "application/json" ], "tags": [ - "auth" + "admin" ], - "summary": "邮箱注册", + "summary": "管理员获取用户列表", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/auth.RegisterRequest" - } + "type": "string", + "name": "keyword", + "in": "query" } ], "responses": { @@ -1803,7 +1807,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.RegisterResponse" + "$ref": "#/definitions/admin.ListUsersResponse" }, "msg": { "type": "string" @@ -1820,25 +1824,31 @@ } } }, - "/auth/reset-password": { - "post": { - "description": "重置密码", - "consumes": [ - "application/json" + "/admin/users/{id}/admin-level": { + "put": { + "description": "更新用户管理员等级", + "consumes": [ + "application/json" ], "produces": [ "application/json" ], "tags": [ - "auth" + "admin" ], - "summary": "重置密码", + "summary": "更新用户管理员等级", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.ResetPasswordRequest" + "$ref": "#/definitions/admin.UpdateUserAdminLevelRequest" } } ], @@ -1851,7 +1861,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.ResetPasswordResponse" + "$ref": "#/definitions/admin.UpdateUserAdminLevelResponse" }, "msg": { "type": "string" @@ -1868,9 +1878,9 @@ } } }, - "/auth/send-register-code": { - "post": { - "description": "发送注册验证码", + "/admin/users/{id}/status": { + "put": { + "description": "更新用户状态(封禁/解封)", "consumes": [ "application/json" ], @@ -1878,15 +1888,21 @@ "application/json" ], "tags": [ - "auth" + "admin" ], - "summary": "发送注册验证码", + "summary": "更新用户状态(封禁/解封)", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.SendRegisterCodeRequest" + "$ref": "#/definitions/admin.UpdateUserStatusRequest" } } ], @@ -1899,7 +1915,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.SendRegisterCodeResponse" + "$ref": "#/definitions/admin.UpdateUserStatusResponse" }, "msg": { "type": "string" @@ -1916,9 +1932,9 @@ } } }, - "/auth/webauthn/credentials": { + "/announcements": { "get": { - "description": "列出用户的 WebAuthn 凭证", + "description": "公告列表", "consumes": [ "application/json" ], @@ -1926,9 +1942,16 @@ "application/json" ], "tags": [ - "auth/webauthn" + "announcement" + ], + "summary": "公告列表", + "parameters": [ + { + "type": "string", + "name": "locale", + "in": "query" + } ], - "summary": "列出用户的 WebAuthn 凭证", "responses": { "200": { "description": "OK", @@ -1938,7 +1961,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnListCredentialsResponse" + "$ref": "#/definitions/announcement.ListResponse" }, "msg": { "type": "string" @@ -1955,9 +1978,9 @@ } } }, - "/auth/webauthn/credentials/{credentialId}": { - "put": { - "description": "重命名凭证", + "/announcements/latest": { + "get": { + "description": "最新重要公告", "consumes": [ "application/json" ], @@ -1965,22 +1988,14 @@ "application/json" ], "tags": [ - "auth/webauthn" + "announcement" ], - "summary": "重命名凭证", + "summary": "最新重要公告", "parameters": [ { - "type": "integer", - "name": "credentialId", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/auth.WebAuthnRenameCredentialRequest" - } + "type": "string", + "name": "locale", + "in": "query" } ], "responses": { @@ -1992,7 +2007,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnRenameCredentialResponse" + "$ref": "#/definitions/announcement.LatestResponse" }, "msg": { "type": "string" @@ -2007,9 +2022,11 @@ } } } - }, - "delete": { - "description": "删除凭证", + } + }, + "/audit-logs": { + "get": { + "description": "全局管理日志(公开,仅返回管理员删除的脚本)", "consumes": [ "application/json" ], @@ -2017,24 +2034,9 @@ "application/json" ], "tags": [ - "auth/webauthn" - ], - "summary": "删除凭证", - "parameters": [ - { - "type": "integer", - "name": "credentialId", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/auth.WebAuthnDeleteCredentialRequest" - } - } + "audit/audit_log" ], + "summary": "全局管理日志(公开,仅返回管理员删除的脚本)", "responses": { "200": { "description": "OK", @@ -2044,7 +2046,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnDeleteCredentialResponse" + "$ref": "#/definitions/audit.ListResponse" }, "msg": { "type": "string" @@ -2061,9 +2063,9 @@ } } }, - "/auth/webauthn/login/begin": { + "/auth/forgot-password": { "post": { - "description": "2FA: 开始 WebAuthn 登录验证", + "description": "忘记密码", "consumes": [ "application/json" ], @@ -2071,15 +2073,15 @@ "application/json" ], "tags": [ - "auth/webauthn" + "auth" ], - "summary": "2FA: 开始 WebAuthn 登录验证", + "summary": "忘记密码", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.WebAuthnLoginBeginRequest" + "$ref": "#/definitions/auth.ForgotPasswordRequest" } } ], @@ -2092,7 +2094,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnLoginBeginResponse" + "$ref": "#/definitions/auth.ForgotPasswordResponse" }, "msg": { "type": "string" @@ -2109,9 +2111,9 @@ } } }, - "/auth/webauthn/login/finish": { + "/auth/login": { "post": { - "description": "2FA: 完成 WebAuthn 登录验证", + "description": "登录(支持邮箱或用户名)", "consumes": [ "application/json" ], @@ -2119,15 +2121,15 @@ "application/json" ], "tags": [ - "auth/webauthn" + "auth" ], - "summary": "2FA: 完成 WebAuthn 登录验证", + "summary": "登录(支持邮箱或用户名)", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.WebAuthnLoginFinishRequest" + "$ref": "#/definitions/auth.LoginRequest" } } ], @@ -2140,7 +2142,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnLoginFinishResponse" + "$ref": "#/definitions/auth.LoginResponse" }, "msg": { "type": "string" @@ -2157,9 +2159,9 @@ } } }, - "/auth/webauthn/passless/begin": { + "/auth/oidc/bindconfirm": { "post": { - "description": "无密码登录: 开始", + "description": "POST /auth/oidc/bindconfirm — 登录已有账号并绑定 OIDC", "consumes": [ "application/json" ], @@ -2167,15 +2169,15 @@ "application/json" ], "tags": [ - "auth/webauthn" + "auth/oidc_login" ], - "summary": "无密码登录: 开始", + "summary": "POST /auth/oidc/bindconfirm — 登录已有账号并绑定 OIDC", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.WebAuthnPasswordlessBeginRequest" + "$ref": "#/definitions/auth.OIDCBindConfirmRequest" } } ], @@ -2188,7 +2190,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnPasswordlessBeginResponse" + "$ref": "#/definitions/auth.OIDCBindConfirmResponse" }, "msg": { "type": "string" @@ -2205,9 +2207,9 @@ } } }, - "/auth/webauthn/passless/finish": { - "post": { - "description": "无密码登录: 完成", + "/auth/oidc/bindinfo": { + "get": { + "description": "GET /auth/oidc/bindinfo — 获取待绑定的 OIDC 信息", "consumes": [ "application/json" ], @@ -2215,16 +2217,14 @@ "application/json" ], "tags": [ - "auth/webauthn" + "auth/oidc_login" ], - "summary": "无密码登录: 完成", + "summary": "GET /auth/oidc/bindinfo — 获取待绑定的 OIDC 信息", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/auth.WebAuthnPasswordlessFinishRequest" - } + "type": "string", + "name": "bind_token", + "in": "query" } ], "responses": { @@ -2236,7 +2236,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnPasswordlessFinishResponse" + "$ref": "#/definitions/auth.OIDCBindInfoResponse" }, "msg": { "type": "string" @@ -2253,9 +2253,9 @@ } } }, - "/auth/webauthn/register/begin": { - "post": { - "description": "开始 WebAuthn 注册", + "/auth/oidc/providers": { + "get": { + "description": "GET /auth/oidc/providers — 获取可用的 OIDC 登录提供商列表(公开接口)", "consumes": [ "application/json" ], @@ -2263,18 +2263,9 @@ "application/json" ], "tags": [ - "auth/webauthn" - ], - "summary": "开始 WebAuthn 注册", - "parameters": [ - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/auth.WebAuthnRegisterBeginRequest" - } - } + "auth/oidc_login" ], + "summary": "GET /auth/oidc/providers — 获取可用的 OIDC 登录提供商列表(公开接口)", "responses": { "200": { "description": "OK", @@ -2284,7 +2275,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnRegisterBeginResponse" + "$ref": "#/definitions/auth.OIDCProvidersResponse" }, "msg": { "type": "string" @@ -2301,9 +2292,9 @@ } } }, - "/auth/webauthn/register/finish": { + "/auth/oidc/register": { "post": { - "description": "完成 WebAuthn 注册", + "description": "POST /auth/oidc/register — 注册新账号并绑定 OIDC", "consumes": [ "application/json" ], @@ -2311,15 +2302,15 @@ "application/json" ], "tags": [ - "auth/webauthn" + "auth/oidc_login" ], - "summary": "完成 WebAuthn 注册", + "summary": "POST /auth/oidc/register — 注册新账号并绑定 OIDC", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.WebAuthnRegisterFinishRequest" + "$ref": "#/definitions/auth.OIDCRegisterAndBindRequest" } } ], @@ -2332,7 +2323,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.WebAuthnRegisterFinishResponse" + "$ref": "#/definitions/auth.OIDCRegisterAndBindResponse" }, "msg": { "type": "string" @@ -2349,9 +2340,9 @@ } } }, - "/chat/sessions": { - "get": { - "description": "获取会话列表", + "/auth/register": { + "post": { + "description": "邮箱注册", "consumes": [ "application/json" ], @@ -2359,9 +2350,18 @@ "application/json" ], "tags": [ - "chat" + "auth" + ], + "summary": "邮箱注册", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/auth.RegisterRequest" + } + } ], - "summary": "获取会话列表", "responses": { "200": { "description": "OK", @@ -2371,7 +2371,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/chat.ListSessionsResponse" + "$ref": "#/definitions/auth.RegisterResponse" }, "msg": { "type": "string" @@ -2386,9 +2386,11 @@ } } } - }, + } + }, + "/auth/reset-password": { "post": { - "description": "创建会话", + "description": "重置密码", "consumes": [ "application/json" ], @@ -2396,15 +2398,15 @@ "application/json" ], "tags": [ - "chat" + "auth" ], - "summary": "创建会话", + "summary": "重置密码", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/chat.CreateSessionRequest" + "$ref": "#/definitions/auth.ResetPasswordRequest" } } ], @@ -2417,7 +2419,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/chat.CreateSessionResponse" + "$ref": "#/definitions/auth.ResetPasswordResponse" }, "msg": { "type": "string" @@ -2434,9 +2436,9 @@ } } }, - "/chat/sessions/{sessionId}": { - "delete": { - "description": "删除会话", + "/auth/send-register-code": { + "post": { + "description": "发送注册验证码", "consumes": [ "application/json" ], @@ -2444,21 +2446,15 @@ "application/json" ], "tags": [ - "chat" + "auth" ], - "summary": "删除会话", + "summary": "发送注册验证码", "parameters": [ - { - "type": "integer", - "name": "sessionId", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/chat.DeleteSessionRequest" + "$ref": "#/definitions/auth.SendRegisterCodeRequest" } } ], @@ -2471,7 +2467,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/chat.DeleteSessionResponse" + "$ref": "#/definitions/auth.SendRegisterCodeResponse" }, "msg": { "type": "string" @@ -2488,9 +2484,9 @@ } } }, - "/chat/sessions/{sessionId}/messages": { + "/auth/webauthn/credentials": { "get": { - "description": "获取会话消息列表", + "description": "列出用户的 WebAuthn 凭证", "consumes": [ "application/json" ], @@ -2498,17 +2494,9 @@ "application/json" ], "tags": [ - "chat" - ], - "summary": "获取会话消息列表", - "parameters": [ - { - "type": "integer", - "name": "sessionId", - "in": "path", - "required": true - } + "auth/webauthn" ], + "summary": "列出用户的 WebAuthn 凭证", "responses": { "200": { "description": "OK", @@ -2518,7 +2506,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/chat.ListMessagesResponse" + "$ref": "#/definitions/auth.WebAuthnListCredentialsResponse" }, "msg": { "type": "string" @@ -2535,9 +2523,9 @@ } } }, - "/favorites/folders": { - "get": { - "description": "收藏夹列表", + "/auth/webauthn/credentials/{credentialId}": { + "put": { + "description": "重命名凭证", "consumes": [ "application/json" ], @@ -2545,60 +2533,21 @@ "application/json" ], "tags": [ - "script/favorite" + "auth/webauthn" ], - "summary": "收藏夹列表", + "summary": "重命名凭证", "parameters": [ { "type": "integer", - "description": "用户ID,0表示当前登录用户", - "name": "user_id", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "properties": { - "code": { - "type": "integer" - }, - "data": { - "$ref": "#/definitions/script.FavoriteFolderListResponse" - }, - "msg": { - "type": "string" - } - } - } + "name": "credentialId", + "in": "path", + "required": true }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/BadRequest" - } - } - } - }, - "post": { - "description": "创建收藏夹", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "script/favorite" - ], - "summary": "创建收藏夹", - "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.CreateFolderRequest" + "$ref": "#/definitions/auth.WebAuthnRenameCredentialRequest" } } ], @@ -2611,7 +2560,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.CreateFolderResponse" + "$ref": "#/definitions/auth.WebAuthnRenameCredentialResponse" }, "msg": { "type": "string" @@ -2626,11 +2575,9 @@ } } } - } - }, - "/favorites/folders/{id}": { - "put": { - "description": "编辑收藏夹", + }, + "delete": { + "description": "删除凭证", "consumes": [ "application/json" ], @@ -2638,13 +2585,13 @@ "application/json" ], "tags": [ - "script/favorite" + "auth/webauthn" ], - "summary": "编辑收藏夹", + "summary": "删除凭证", "parameters": [ { "type": "integer", - "name": "id", + "name": "credentialId", "in": "path", "required": true }, @@ -2652,7 +2599,7 @@ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.EditFolderRequest" + "$ref": "#/definitions/auth.WebAuthnDeleteCredentialRequest" } } ], @@ -2665,7 +2612,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.EditFolderResponse" + "$ref": "#/definitions/auth.WebAuthnDeleteCredentialResponse" }, "msg": { "type": "string" @@ -2680,9 +2627,11 @@ } } } - }, - "delete": { - "description": "删除收藏夹", + } + }, + "/auth/webauthn/login/begin": { + "post": { + "description": "2FA: 开始 WebAuthn 登录验证", "consumes": [ "application/json" ], @@ -2690,21 +2639,15 @@ "application/json" ], "tags": [ - "script/favorite" + "auth/webauthn" ], - "summary": "删除收藏夹", + "summary": "2FA: 开始 WebAuthn 登录验证", "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.DeleteFolderRequest" + "$ref": "#/definitions/auth.WebAuthnLoginBeginRequest" } } ], @@ -2717,7 +2660,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.DeleteFolderResponse" + "$ref": "#/definitions/auth.WebAuthnLoginBeginResponse" }, "msg": { "type": "string" @@ -2734,9 +2677,9 @@ } } }, - "/favorites/folders/{id}/detail": { - "get": { - "description": "收藏夹详情", + "/auth/webauthn/login/finish": { + "post": { + "description": "2FA: 完成 WebAuthn 登录验证", "consumes": [ "application/json" ], @@ -2744,15 +2687,16 @@ "application/json" ], "tags": [ - "script/favorite" + "auth/webauthn" ], - "summary": "收藏夹详情", + "summary": "2FA: 完成 WebAuthn 登录验证", "parameters": [ { - "type": "integer", - "name": "id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/auth.WebAuthnLoginFinishRequest" + } } ], "responses": { @@ -2764,7 +2708,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.FavoriteFolderDetailResponse" + "$ref": "#/definitions/auth.WebAuthnLoginFinishResponse" }, "msg": { "type": "string" @@ -2781,9 +2725,9 @@ } } }, - "/favorites/folders/{id}/favorite": { + "/auth/webauthn/passless/begin": { "post": { - "description": "收藏脚本", + "description": "无密码登录: 开始", "consumes": [ "application/json" ], @@ -2791,22 +2735,15 @@ "application/json" ], "tags": [ - "script/favorite" + "auth/webauthn" ], - "summary": "收藏脚本", + "summary": "无密码登录: 开始", "parameters": [ - { - "type": "integer", - "description": "一次只能收藏到一个收藏夹", - "name": "id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.FavoriteScriptRequest" + "$ref": "#/definitions/auth.WebAuthnPasswordlessBeginRequest" } } ], @@ -2819,7 +2756,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.FavoriteScriptResponse" + "$ref": "#/definitions/auth.WebAuthnPasswordlessBeginResponse" }, "msg": { "type": "string" @@ -2834,9 +2771,11 @@ } } } - }, - "delete": { - "description": "取消收藏脚本", + } + }, + "/auth/webauthn/passless/finish": { + "post": { + "description": "无密码登录: 完成", "consumes": [ "application/json" ], @@ -2844,22 +2783,15 @@ "application/json" ], "tags": [ - "script/favorite" + "auth/webauthn" ], - "summary": "取消收藏脚本", + "summary": "无密码登录: 完成", "parameters": [ - { - "type": "integer", - "description": "一次只能从一个收藏夹移除,如果为0表示从所有收藏夹移除", - "name": "id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.UnfavoriteScriptRequest" + "$ref": "#/definitions/auth.WebAuthnPasswordlessFinishRequest" } } ], @@ -2872,7 +2804,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UnfavoriteScriptResponse" + "$ref": "#/definitions/auth.WebAuthnPasswordlessFinishResponse" }, "msg": { "type": "string" @@ -2889,9 +2821,9 @@ } } }, - "/favorites/scripts": { - "get": { - "description": "获取收藏夹脚本列表", + "/auth/webauthn/register/begin": { + "post": { + "description": "开始 WebAuthn 注册", "consumes": [ "application/json" ], @@ -2899,21 +2831,16 @@ "application/json" ], "tags": [ - "script/favorite" + "auth/webauthn" ], - "summary": "获取收藏夹脚本列表", + "summary": "开始 WebAuthn 注册", "parameters": [ { - "type": "integer", - "description": "收藏夹ID,0表示所有的收藏", - "name": "folder_id", - "in": "query" - }, - { - "type": "integer", - "description": "用户ID,0表示当前登录用户", - "name": "user_id", - "in": "query" + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/auth.WebAuthnRegisterBeginRequest" + } } ], "responses": { @@ -2925,7 +2852,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.FavoriteScriptListResponse" + "$ref": "#/definitions/auth.WebAuthnRegisterBeginResponse" }, "msg": { "type": "string" @@ -2942,9 +2869,9 @@ } } }, - "/feedback": { + "/auth/webauthn/register/finish": { "post": { - "description": "用户反馈请求", + "description": "完成 WebAuthn 注册", "consumes": [ "application/json" ], @@ -2952,15 +2879,15 @@ "application/json" ], "tags": [ - "system" + "auth/webauthn" ], - "summary": "用户反馈请求", + "summary": "完成 WebAuthn 注册", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/system.FeedbackRequest" + "$ref": "#/definitions/auth.WebAuthnRegisterFinishRequest" } } ], @@ -2973,7 +2900,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/system.FeedbackResponse" + "$ref": "#/definitions/auth.WebAuthnRegisterFinishResponse" }, "msg": { "type": "string" @@ -2990,9 +2917,9 @@ } } }, - "/global-config": { + "/chat/sessions": { "get": { - "description": "获取全局配置(公开接口)", + "description": "获取会话列表", "consumes": [ "application/json" ], @@ -3000,9 +2927,9 @@ "application/json" ], "tags": [ - "system" + "chat" ], - "summary": "获取全局配置(公开接口)", + "summary": "获取会话列表", "responses": { "200": { "description": "OK", @@ -3012,7 +2939,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/system.GetGlobalConfigResponse" + "$ref": "#/definitions/chat.ListSessionsResponse" }, "msg": { "type": "string" @@ -3027,11 +2954,9 @@ } } } - } - }, - "/notifications": { - "get": { - "description": "获取通知列表", + }, + "post": { + "description": "创建会话", "consumes": [ "application/json" ], @@ -3039,15 +2964,16 @@ "application/json" ], "tags": [ - "notification" + "chat" ], - "summary": "获取通知列表", + "summary": "创建会话", "parameters": [ { - "type": "integer", - "description": "0:全部 1:未读 2:已读", - "name": "read_status", - "in": "query" + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/chat.CreateSessionRequest" + } } ], "responses": { @@ -3059,7 +2985,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/notification.ListResponse" + "$ref": "#/definitions/chat.CreateSessionResponse" }, "msg": { "type": "string" @@ -3076,25 +3002,31 @@ } } }, - "/notifications/read": { - "put": { - "description": "批量标记已读", - "consumes": [ - "application/json" + "/chat/sessions/{sessionId}": { + "delete": { + "description": "删除会话", + "consumes": [ + "application/json" ], "produces": [ "application/json" ], "tags": [ - "notification" + "chat" ], - "summary": "批量标记已读", + "summary": "删除会话", "parameters": [ + { + "type": "integer", + "name": "sessionId", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/notification.BatchMarkReadRequest" + "$ref": "#/definitions/chat.DeleteSessionRequest" } } ], @@ -3107,7 +3039,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/notification.BatchMarkReadResponse" + "$ref": "#/definitions/chat.DeleteSessionResponse" }, "msg": { "type": "string" @@ -3124,48 +3056,9 @@ } } }, - "/notifications/unread-count": { + "/chat/sessions/{sessionId}/messages": { "get": { - "description": "获取未读通知数量", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "notification" - ], - "summary": "获取未读通知数量", - "responses": { - "200": { - "description": "OK", - "schema": { - "properties": { - "code": { - "type": "integer" - }, - "data": { - "$ref": "#/definitions/notification.GetUnreadCountResponse" - }, - "msg": { - "type": "string" - } - } - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/BadRequest" - } - } - } - } - }, - "/notifications/{id}/read": { - "put": { - "description": "标记通知为已读", + "description": "获取会话消息列表", "consumes": [ "application/json" ], @@ -3173,22 +3066,15 @@ "application/json" ], "tags": [ - "notification" + "chat" ], - "summary": "标记通知为已读", + "summary": "获取会话消息列表", "parameters": [ { "type": "integer", - "name": "id", + "name": "sessionId", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/notification.MarkReadRequest" - } } ], "responses": { @@ -3200,7 +3086,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/notification.MarkReadResponse" + "$ref": "#/definitions/chat.ListMessagesResponse" }, "msg": { "type": "string" @@ -3217,9 +3103,9 @@ } } }, - "/oauth/authorize": { + "/favorites/folders": { "get": { - "description": "GET /oauth/authorize — 授权页(重定向自客户端)", + "description": "收藏夹列表", "consumes": [ "application/json" ], @@ -3227,38 +3113,14 @@ "application/json" ], "tags": [ - "auth/oauth2" + "script/favorite" ], - "summary": "GET /oauth/authorize — 授权页(重定向自客户端)", + "summary": "收藏夹列表", "parameters": [ { - "type": "string", - "name": "client_id", - "in": "query" - }, - { - "type": "string", - "name": "redirect_uri", - "in": "query" - }, - { - "type": "string", - "name": "response_type", - "in": "query" - }, - { - "type": "string", - "name": "scope", - "in": "query" - }, - { - "type": "string", - "name": "state", - "in": "query" - }, - { - "type": "string", - "name": "nonce", + "type": "integer", + "description": "用户ID,0表示当前登录用户", + "name": "user_id", "in": "query" } ], @@ -3271,7 +3133,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OAuth2AuthorizeResponse" + "$ref": "#/definitions/script.FavoriteFolderListResponse" }, "msg": { "type": "string" @@ -3288,7 +3150,7 @@ } }, "post": { - "description": "POST /oauth/authorize — 用户批准授权", + "description": "创建收藏夹", "consumes": [ "application/json" ], @@ -3296,15 +3158,15 @@ "application/json" ], "tags": [ - "auth/oauth2" + "script/favorite" ], - "summary": "POST /oauth/authorize — 用户批准授权", + "summary": "创建收藏夹", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.OAuth2ApproveRequest" + "$ref": "#/definitions/script.CreateFolderRequest" } } ], @@ -3317,7 +3179,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OAuth2ApproveResponse" + "$ref": "#/definitions/script.CreateFolderResponse" }, "msg": { "type": "string" @@ -3334,9 +3196,9 @@ } } }, - "/oauth/jwks": { - "get": { - "description": "GET /oauth/jwks", + "/favorites/folders/{id}": { + "put": { + "description": "编辑收藏夹", "consumes": [ "application/json" ], @@ -3344,9 +3206,24 @@ "application/json" ], "tags": [ - "auth/oidc" + "script/favorite" + ], + "summary": "编辑收藏夹", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.EditFolderRequest" + } + } ], - "summary": "GET /oauth/jwks", "responses": { "200": { "description": "OK", @@ -3356,7 +3233,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OIDCJWKSResponse" + "$ref": "#/definitions/script.EditFolderResponse" }, "msg": { "type": "string" @@ -3371,11 +3248,9 @@ } } } - } - }, - "/oauth/token": { - "post": { - "description": "POST /oauth/token — 用 code 换 access_token", + }, + "delete": { + "description": "删除收藏夹", "consumes": [ "application/json" ], @@ -3383,15 +3258,21 @@ "application/json" ], "tags": [ - "auth/oauth2" + "script/favorite" ], - "summary": "POST /oauth/token — 用 code 换 access_token", + "summary": "删除收藏夹", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/auth.OAuth2TokenRequest" + "$ref": "#/definitions/script.DeleteFolderRequest" } } ], @@ -3404,7 +3285,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OAuth2TokenResponse" + "$ref": "#/definitions/script.DeleteFolderResponse" }, "msg": { "type": "string" @@ -3421,9 +3302,9 @@ } } }, - "/oauth/userinfo": { + "/favorites/folders/{id}/detail": { "get": { - "description": "GET /oauth/userinfo — 获取用户信息", + "description": "收藏夹详情", "consumes": [ "application/json" ], @@ -3431,9 +3312,17 @@ "application/json" ], "tags": [ - "auth/oauth2" + "script/favorite" + ], + "summary": "收藏夹详情", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + } ], - "summary": "GET /oauth/userinfo — 获取用户信息", "responses": { "200": { "description": "OK", @@ -3443,7 +3332,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.OAuth2UserInfoResponse" + "$ref": "#/definitions/script.FavoriteFolderDetailResponse" }, "msg": { "type": "string" @@ -3460,9 +3349,9 @@ } } }, - "/open/crx-download/{id}": { - "get": { - "description": "谷歌crx下载服务", + "/favorites/folders/{id}/favorite": { + "post": { + "description": "收藏脚本", "consumes": [ "application/json" ], @@ -3470,15 +3359,23 @@ "application/json" ], "tags": [ - "open" + "script/favorite" ], - "summary": "谷歌crx下载服务", + "summary": "收藏脚本", "parameters": [ { "type": "integer", + "description": "一次只能收藏到一个收藏夹", "name": "id", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.FavoriteScriptRequest" + } } ], "responses": { @@ -3490,7 +3387,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/open.CrxDownloadResponse" + "$ref": "#/definitions/script.FavoriteScriptResponse" }, "msg": { "type": "string" @@ -3505,11 +3402,9 @@ } } } - } - }, - "/resource/image": { - "post": { - "description": "上传图片", + }, + "delete": { + "description": "取消收藏脚本", "consumes": [ "application/json" ], @@ -3517,15 +3412,22 @@ "application/json" ], "tags": [ - "resource" + "script/favorite" ], - "summary": "上传图片", + "summary": "取消收藏脚本", "parameters": [ + { + "type": "integer", + "description": "一次只能从一个收藏夹移除,如果为0表示从所有收藏夹移除", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/resource.UploadImageRequest" + "$ref": "#/definitions/script.UnfavoriteScriptRequest" } } ], @@ -3538,7 +3440,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/resource.UploadImageResponse" + "$ref": "#/definitions/script.UnfavoriteScriptResponse" }, "msg": { "type": "string" @@ -3555,9 +3457,9 @@ } } }, - "/script/{id}/statistics": { + "/favorites/scripts": { "get": { - "description": "脚本统计数据", + "description": "获取收藏夹脚本列表", "consumes": [ "application/json" ], @@ -3565,27 +3467,33 @@ "application/json" ], "tags": [ - "statistics" + "script/favorite" ], - "summary": "脚本统计数据", + "summary": "获取收藏夹脚本列表", "parameters": [ { "type": "integer", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "properties": { - "code": { + "description": "收藏夹ID,0表示所有的收藏", + "name": "folder_id", + "in": "query" + }, + { + "type": "integer", + "description": "用户ID,0表示当前登录用户", + "name": "user_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { "type": "integer" }, "data": { - "$ref": "#/definitions/statistics.ScriptResponse" + "$ref": "#/definitions/script.FavoriteScriptListResponse" }, "msg": { "type": "string" @@ -3602,9 +3510,9 @@ } } }, - "/script/{id}/statistics/realtime": { - "get": { - "description": "脚本实时统计数据", + "/feedback": { + "post": { + "description": "用户反馈请求", "consumes": [ "application/json" ], @@ -3612,15 +3520,16 @@ "application/json" ], "tags": [ - "statistics" + "system" ], - "summary": "脚本实时统计数据", + "summary": "用户反馈请求", "parameters": [ { - "type": "integer", - "name": "id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/system.FeedbackRequest" + } } ], "responses": { @@ -3632,7 +3541,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/statistics.ScriptRealtimeResponse" + "$ref": "#/definitions/system.FeedbackResponse" }, "msg": { "type": "string" @@ -3649,9 +3558,9 @@ } } }, - "/scripts": { + "/global-config": { "get": { - "description": "获取脚本列表", + "description": "获取全局配置(公开接口)", "consumes": [ "application/json" ], @@ -3659,44 +3568,9 @@ "application/json" ], "tags": [ - "script" - ], - "summary": "获取脚本列表", - "parameters": [ - { - "type": "string", - "name": "keyword", - "in": "query" - }, - { - "type": "string", - "name": "domain", - "in": "query" - }, - { - "type": "integer", - "description": "用户ID", - "name": "user_id", - "in": "query" - }, - { - "type": "integer", - "description": "分类ID", - "name": "category", - "in": "query" - }, - { - "type": "integer", - "description": "0:全部 1: 脚本 2: 库 3: 后台脚本 4: 定时脚本", - "name": "script_type,default=0", - "in": "query" - }, - { - "type": "string", - "name": "sort,default=today_download", - "in": "query" - } + "system" ], + "summary": "获取全局配置(公开接口)", "responses": { "200": { "description": "OK", @@ -3706,7 +3580,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.ListResponse" + "$ref": "#/definitions/system.GetGlobalConfigResponse" }, "msg": { "type": "string" @@ -3721,9 +3595,11 @@ } } } - }, - "post": { - "description": "创建脚本", + } + }, + "/notifications": { + "get": { + "description": "获取通知列表", "consumes": [ "application/json" ], @@ -3731,16 +3607,15 @@ "application/json" ], "tags": [ - "script" + "notification" ], - "summary": "创建脚本", + "summary": "获取通知列表", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.CreateRequest" - } + "type": "integer", + "description": "0:全部 1:未读 2:已读", + "name": "read_status", + "in": "query" } ], "responses": { @@ -3752,7 +3627,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.CreateResponse" + "$ref": "#/definitions/notification.ListResponse" }, "msg": { "type": "string" @@ -3769,9 +3644,9 @@ } } }, - "/scripts/:id/gray": { + "/notifications/read": { "put": { - "description": "更新脚本灰度策略", + "description": "批量标记已读", "consumes": [ "application/json" ], @@ -3779,15 +3654,15 @@ "application/json" ], "tags": [ - "script" + "notification" ], - "summary": "更新脚本灰度策略", + "summary": "批量标记已读", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.UpdateScriptGrayRequest" + "$ref": "#/definitions/notification.BatchMarkReadRequest" } } ], @@ -3800,7 +3675,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateScriptGrayResponse" + "$ref": "#/definitions/notification.BatchMarkReadResponse" }, "msg": { "type": "string" @@ -3817,9 +3692,9 @@ } } }, - "/scripts/:id/lib-info": { - "put": { - "description": "更新库信息", + "/notifications/unread-count": { + "get": { + "description": "获取未读通知数量", "consumes": [ "application/json" ], @@ -3827,18 +3702,9 @@ "application/json" ], "tags": [ - "script" - ], - "summary": "更新库信息", - "parameters": [ - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.UpdateLibInfoRequest" - } - } + "notification" ], + "summary": "获取未读通知数量", "responses": { "200": { "description": "OK", @@ -3848,7 +3714,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateLibInfoResponse" + "$ref": "#/definitions/notification.GetUnreadCountResponse" }, "msg": { "type": "string" @@ -3865,9 +3731,9 @@ } } }, - "/scripts/category": { - "get": { - "description": "脚本分类列表", + "/notifications/{id}/read": { + "put": { + "description": "标记通知为已读", "consumes": [ "application/json" ], @@ -3875,25 +3741,22 @@ "application/json" ], "tags": [ - "script/category" + "notification" ], - "summary": "脚本分类列表", + "summary": "标记通知为已读", "parameters": [ { - "type": "string", - "description": "前缀", - "name": "prefix", - "in": "query" + "type": "integer", + "name": "id", + "in": "path", + "required": true }, { - "enum": [ - 1, - 2 - ], - "type": "integer", - "description": "分类类型: 1: 脚本分类, 2: Tag\nScriptCategoryType enum type:\n- ScriptCategoryTypeCategory: 1\n- ScriptCategoryTypeTag: 2", - "name": "type", - "in": "query" + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/notification.MarkReadRequest" + } } ], "responses": { @@ -3905,7 +3768,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.CategoryListResponse" + "$ref": "#/definitions/notification.MarkReadResponse" }, "msg": { "type": "string" @@ -3922,9 +3785,9 @@ } } }, - "/scripts/invite/{code}": { + "/oauth/authorize": { "get": { - "description": "邀请码信息", + "description": "GET /oauth/authorize — 授权页(重定向自客户端)", "consumes": [ "application/json" ], @@ -3932,15 +3795,39 @@ "application/json" ], "tags": [ - "script/access_invite" + "auth/oauth2" ], - "summary": "邀请码信息", + "summary": "GET /oauth/authorize — 授权页(重定向自客户端)", "parameters": [ { "type": "string", - "name": "code", - "in": "path", - "required": true + "name": "client_id", + "in": "query" + }, + { + "type": "string", + "name": "redirect_uri", + "in": "query" + }, + { + "type": "string", + "name": "response_type", + "in": "query" + }, + { + "type": "string", + "name": "scope", + "in": "query" + }, + { + "type": "string", + "name": "state", + "in": "query" + }, + { + "type": "string", + "name": "nonce", + "in": "query" } ], "responses": { @@ -3952,7 +3839,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.InviteCodeInfoResponse" + "$ref": "#/definitions/auth.OAuth2AuthorizeResponse" }, "msg": { "type": "string" @@ -3967,11 +3854,9 @@ } } } - } - }, - "/scripts/invite/{code}/accept": { - "put": { - "description": "接受邀请", + }, + "post": { + "description": "POST /oauth/authorize — 用户批准授权", "consumes": [ "application/json" ], @@ -3979,21 +3864,15 @@ "application/json" ], "tags": [ - "script/access_invite" + "auth/oauth2" ], - "summary": "接受邀请", + "summary": "POST /oauth/authorize — 用户批准授权", "parameters": [ - { - "type": "string", - "name": "code", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.AcceptInviteRequest" + "$ref": "#/definitions/auth.OAuth2ApproveRequest" } } ], @@ -4006,7 +3885,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.AcceptInviteResponse" + "$ref": "#/definitions/auth.OAuth2ApproveResponse" }, "msg": { "type": "string" @@ -4023,9 +3902,9 @@ } } }, - "/scripts/last-score": { + "/oauth/jwks": { "get": { - "description": "最新评分脚本", + "description": "GET /oauth/jwks", "consumes": [ "application/json" ], @@ -4033,9 +3912,9 @@ "application/json" ], "tags": [ - "script" + "auth/oidc" ], - "summary": "最新评分脚本", + "summary": "GET /oauth/jwks", "responses": { "200": { "description": "OK", @@ -4045,7 +3924,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.LastScoreResponse" + "$ref": "#/definitions/auth.OIDCJWKSResponse" }, "msg": { "type": "string" @@ -4062,9 +3941,9 @@ } } }, - "/scripts/migrate/es": { + "/oauth/token": { "post": { - "description": "全量迁移数据到es", + "description": "POST /oauth/token — 用 code 换 access_token", "consumes": [ "application/json" ], @@ -4072,15 +3951,15 @@ "application/json" ], "tags": [ - "script" + "auth/oauth2" ], - "summary": "全量迁移数据到es", + "summary": "POST /oauth/token — 用 code 换 access_token", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.MigrateEsRequest" + "$ref": "#/definitions/auth.OAuth2TokenRequest" } } ], @@ -4093,7 +3972,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.MigrateEsResponse" + "$ref": "#/definitions/auth.OAuth2TokenResponse" }, "msg": { "type": "string" @@ -4110,9 +3989,9 @@ } } }, - "/scripts/{id}": { + "/oauth/userinfo": { "get": { - "description": "获取脚本信息", + "description": "GET /oauth/userinfo — 获取用户信息", "consumes": [ "application/json" ], @@ -4120,17 +3999,9 @@ "application/json" ], "tags": [ - "script" - ], - "summary": "获取脚本信息", - "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - } + "auth/oauth2" ], + "summary": "GET /oauth/userinfo — 获取用户信息", "responses": { "200": { "description": "OK", @@ -4140,7 +4011,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.InfoResponse" + "$ref": "#/definitions/auth.OAuth2UserInfoResponse" }, "msg": { "type": "string" @@ -4155,9 +4026,11 @@ } } } - }, - "delete": { - "description": "删除脚本", + } + }, + "/open/crx-download/{id}": { + "get": { + "description": "谷歌crx下载服务", "consumes": [ "application/json" ], @@ -4165,22 +4038,15 @@ "application/json" ], "tags": [ - "script" + "open" ], - "summary": "删除脚本", + "summary": "谷歌crx下载服务", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.DeleteRequest" - } } ], "responses": { @@ -4192,7 +4058,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.DeleteResponse" + "$ref": "#/definitions/open.CrxDownloadResponse" }, "msg": { "type": "string" @@ -4209,9 +4075,9 @@ } } }, - "/scripts/{id}/access": { - "get": { - "description": "访问控制列表", + "/resource/image": { + "post": { + "description": "上传图片", "consumes": [ "application/json" ], @@ -4219,15 +4085,16 @@ "application/json" ], "tags": [ - "script/access" + "resource" ], - "summary": "访问控制列表", + "summary": "上传图片", "parameters": [ { - "type": "integer", - "name": "id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/resource.UploadImageRequest" + } } ], "responses": { @@ -4239,7 +4106,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.AccessListResponse" + "$ref": "#/definitions/resource.UploadImageResponse" }, "msg": { "type": "string" @@ -4256,9 +4123,9 @@ } } }, - "/scripts/{id}/access/group": { - "post": { - "description": "添加组权限", + "/script/{id}/statistics": { + "get": { + "description": "脚本统计数据", "consumes": [ "application/json" ], @@ -4266,22 +4133,15 @@ "application/json" ], "tags": [ - "script/access" + "statistics" ], - "summary": "添加组权限", + "summary": "脚本统计数据", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.AddGroupAccessRequest" - } } ], "responses": { @@ -4293,7 +4153,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.AddGroupAccessResponse" + "$ref": "#/definitions/statistics.ScriptResponse" }, "msg": { "type": "string" @@ -4310,9 +4170,9 @@ } } }, - "/scripts/{id}/access/user": { - "post": { - "description": "添加用户权限, 通过用户名进行邀请", + "/script/{id}/statistics/realtime": { + "get": { + "description": "脚本实时统计数据", "consumes": [ "application/json" ], @@ -4320,22 +4180,15 @@ "application/json" ], "tags": [ - "script/access" + "statistics" ], - "summary": "添加用户权限, 通过用户名进行邀请", + "summary": "脚本实时统计数据", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.AddUserAccessRequest" - } } ], "responses": { @@ -4347,7 +4200,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.AddUserAccessResponse" + "$ref": "#/definitions/statistics.ScriptRealtimeResponse" }, "msg": { "type": "string" @@ -4364,9 +4217,9 @@ } } }, - "/scripts/{id}/access/{aid}": { - "put": { - "description": "更新访问控制", + "/scripts": { + "get": { + "description": "获取脚本列表", "consumes": [ "application/json" ], @@ -4374,28 +4227,42 @@ "application/json" ], "tags": [ - "script/access" + "script" ], - "summary": "更新访问控制", + "summary": "获取脚本列表", "parameters": [ + { + "type": "string", + "name": "keyword", + "in": "query" + }, + { + "type": "string", + "name": "domain", + "in": "query" + }, { "type": "integer", - "name": "id", - "in": "path", - "required": true + "description": "用户ID", + "name": "user_id", + "in": "query" }, { "type": "integer", - "name": "aid", - "in": "path", - "required": true + "description": "分类ID", + "name": "category", + "in": "query" }, { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.UpdateAccessRequest" - } + "type": "integer", + "description": "0:全部 1: 脚本 2: 库 3: 后台脚本 4: 定时脚本", + "name": "script_type,default=0", + "in": "query" + }, + { + "type": "string", + "name": "sort,default=today_download", + "in": "query" } ], "responses": { @@ -4407,7 +4274,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateAccessResponse" + "$ref": "#/definitions/script.ListResponse" }, "msg": { "type": "string" @@ -4423,8 +4290,8 @@ } } }, - "delete": { - "description": "删除访问控制", + "post": { + "description": "创建脚本", "consumes": [ "application/json" ], @@ -4432,27 +4299,15 @@ "application/json" ], "tags": [ - "script/access" + "script" ], - "summary": "删除访问控制", + "summary": "创建脚本", "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "name": "aid", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.DeleteAccessRequest" + "$ref": "#/definitions/script.CreateRequest" } } ], @@ -4465,7 +4320,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.DeleteAccessResponse" + "$ref": "#/definitions/script.CreateResponse" }, "msg": { "type": "string" @@ -4482,9 +4337,9 @@ } } }, - "/scripts/{id}/archive": { + "/scripts/:id/gray": { "put": { - "description": "归档脚本", + "description": "更新脚本灰度策略", "consumes": [ "application/json" ], @@ -4494,19 +4349,13 @@ "tags": [ "script" ], - "summary": "归档脚本", + "summary": "更新脚本灰度策略", "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.ArchiveRequest" + "$ref": "#/definitions/script.UpdateScriptGrayRequest" } } ], @@ -4519,7 +4368,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.ArchiveResponse" + "$ref": "#/definitions/script.UpdateScriptGrayResponse" }, "msg": { "type": "string" @@ -4536,9 +4385,9 @@ } } }, - "/scripts/{id}/audit-logs": { - "get": { - "description": "单脚本日志(需要脚本 manage 权限)", + "/scripts/:id/lib-info": { + "put": { + "description": "更新库信息", "consumes": [ "application/json" ], @@ -4546,15 +4395,16 @@ "application/json" ], "tags": [ - "audit/audit_log" + "script" ], - "summary": "单脚本日志(需要脚本 manage 权限)", + "summary": "更新库信息", "parameters": [ { - "type": "integer", - "name": "id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.UpdateLibInfoRequest" + } } ], "responses": { @@ -4566,7 +4416,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/audit.ScriptListResponse" + "$ref": "#/definitions/script.UpdateLibInfoResponse" }, "msg": { "type": "string" @@ -4583,9 +4433,9 @@ } } }, - "/scripts/{id}/code": { + "/scripts/category": { "get": { - "description": "获取脚本代码信息", + "description": "脚本分类列表", "consumes": [ "application/json" ], @@ -4593,15 +4443,25 @@ "application/json" ], "tags": [ - "script" + "script/category" ], - "summary": "获取脚本代码信息", + "summary": "脚本分类列表", "parameters": [ { + "type": "string", + "description": "前缀", + "name": "prefix", + "in": "query" + }, + { + "enum": [ + 1, + 2 + ], "type": "integer", - "name": "id", - "in": "path", - "required": true + "description": "分类类型: 1: 脚本分类, 2: Tag\nScriptCategoryType enum type:\n- ScriptCategoryTypeCategory: 1\n- ScriptCategoryTypeTag: 2", + "name": "type", + "in": "query" } ], "responses": { @@ -4613,7 +4473,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.CodeResponse" + "$ref": "#/definitions/script.CategoryListResponse" }, "msg": { "type": "string" @@ -4628,9 +4488,11 @@ } } } - }, - "put": { - "description": "更新脚本/库代码", + } + }, + "/scripts/invite/{code}": { + "get": { + "description": "邀请码信息", "consumes": [ "application/json" ], @@ -4638,22 +4500,15 @@ "application/json" ], "tags": [ - "script" + "script/access_invite" ], - "summary": "更新脚本/库代码", + "summary": "邀请码信息", "parameters": [ { - "type": "integer", - "name": "id", + "type": "string", + "name": "code", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.UpdateCodeRequest" - } } ], "responses": { @@ -4665,7 +4520,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateCodeResponse" + "$ref": "#/definitions/script.InviteCodeInfoResponse" }, "msg": { "type": "string" @@ -4682,9 +4537,9 @@ } } }, - "/scripts/{id}/code/{codeId}": { + "/scripts/invite/{code}/accept": { "put": { - "description": "更新脚本设置", + "description": "接受邀请", "consumes": [ "application/json" ], @@ -4692,19 +4547,13 @@ "application/json" ], "tags": [ - "script" + "script/access_invite" ], - "summary": "更新脚本设置", + "summary": "接受邀请", "parameters": [ { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "name": "codeId", + "type": "string", + "name": "code", "in": "path", "required": true }, @@ -4712,7 +4561,7 @@ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.UpdateCodeSettingRequest" + "$ref": "#/definitions/script.AcceptInviteRequest" } } ], @@ -4725,7 +4574,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateCodeSettingResponse" + "$ref": "#/definitions/script.AcceptInviteResponse" }, "msg": { "type": "string" @@ -4740,9 +4589,11 @@ } } } - }, - "delete": { - "description": "删除脚本/库代码", + } + }, + "/scripts/last-score": { + "get": { + "description": "最新评分脚本", "consumes": [ "application/json" ], @@ -4752,28 +4603,7 @@ "tags": [ "script" ], - "summary": "删除脚本/库代码", - "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, - { - "type": "integer", - "name": "codeId", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.DeleteCodeRequest" - } - } - ], + "summary": "最新评分脚本", "responses": { "200": { "description": "OK", @@ -4783,7 +4613,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.DeleteCodeResponse" + "$ref": "#/definitions/script.LastScoreResponse" }, "msg": { "type": "string" @@ -4800,8 +4630,9 @@ } } }, - "/scripts/{id}/commentReply": { - "put": { + "/scripts/migrate/es": { + "post": { + "description": "全量迁移数据到es", "consumes": [ "application/json" ], @@ -4809,20 +4640,15 @@ "application/json" ], "tags": [ - "script/score" + "script" ], + "summary": "全量迁移数据到es", "parameters": [ - { - "type": "integer", - "name": "id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.ReplyScoreRequest" + "$ref": "#/definitions/script.MigrateEsRequest" } } ], @@ -4835,7 +4661,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.ReplyScoreResponse" + "$ref": "#/definitions/script.MigrateEsResponse" }, "msg": { "type": "string" @@ -4852,9 +4678,9 @@ } } }, - "/scripts/{id}/group": { + "/scripts/{id}": { "get": { - "description": "群组列表", + "description": "获取脚本信息", "consumes": [ "application/json" ], @@ -4862,20 +4688,15 @@ "application/json" ], "tags": [ - "script/group" + "script" ], - "summary": "群组列表", + "summary": "获取脚本信息", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "type": "string", - "name": "query", - "in": "query" } ], "responses": { @@ -4887,7 +4708,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.GroupListResponse" + "$ref": "#/definitions/script.InfoResponse" }, "msg": { "type": "string" @@ -4903,8 +4724,8 @@ } } }, - "post": { - "description": "创建群组", + "delete": { + "description": "删除脚本", "consumes": [ "application/json" ], @@ -4912,9 +4733,9 @@ "application/json" ], "tags": [ - "script/group" + "script" ], - "summary": "创建群组", + "summary": "删除脚本", "parameters": [ { "type": "integer", @@ -4926,7 +4747,7 @@ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.CreateGroupRequest" + "$ref": "#/definitions/script.DeleteRequest" } } ], @@ -4939,7 +4760,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.CreateGroupResponse" + "$ref": "#/definitions/script.DeleteResponse" }, "msg": { "type": "string" @@ -4956,9 +4777,9 @@ } } }, - "/scripts/{id}/group/{gid}": { - "put": { - "description": "更新群组", + "/scripts/{id}/access": { + "get": { + "description": "访问控制列表", "consumes": [ "application/json" ], @@ -4966,28 +4787,15 @@ "application/json" ], "tags": [ - "script/group" + "script/access" ], - "summary": "更新群组", + "summary": "访问控制列表", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "type": "integer", - "name": "gid", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.UpdateGroupRequest" - } } ], "responses": { @@ -4999,7 +4807,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateGroupResponse" + "$ref": "#/definitions/script.AccessListResponse" }, "msg": { "type": "string" @@ -5014,9 +4822,11 @@ } } } - }, - "delete": { - "description": "删除群组", + } + }, + "/scripts/{id}/access/group": { + "post": { + "description": "添加组权限", "consumes": [ "application/json" ], @@ -5024,9 +4834,9 @@ "application/json" ], "tags": [ - "script/group" + "script/access" ], - "summary": "删除群组", + "summary": "添加组权限", "parameters": [ { "type": "integer", @@ -5034,17 +4844,11 @@ "in": "path", "required": true }, - { - "type": "integer", - "name": "gid", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.DeleteGroupRequest" + "$ref": "#/definitions/script.AddGroupAccessRequest" } } ], @@ -5057,7 +4861,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.DeleteGroupResponse" + "$ref": "#/definitions/script.AddGroupAccessResponse" }, "msg": { "type": "string" @@ -5074,9 +4878,9 @@ } } }, - "/scripts/{id}/group/{gid}/member": { - "get": { - "description": "群组成员列表", + "/scripts/{id}/access/user": { + "post": { + "description": "添加用户权限, 通过用户名进行邀请", "consumes": [ "application/json" ], @@ -5084,9 +4888,9 @@ "application/json" ], "tags": [ - "script/group" + "script/access" ], - "summary": "群组成员列表", + "summary": "添加用户权限, 通过用户名进行邀请", "parameters": [ { "type": "integer", @@ -5095,10 +4899,11 @@ "required": true }, { - "type": "integer", - "name": "gid", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.AddUserAccessRequest" + } } ], "responses": { @@ -5110,7 +4915,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.GroupMemberListResponse" + "$ref": "#/definitions/script.AddUserAccessResponse" }, "msg": { "type": "string" @@ -5125,9 +4930,11 @@ } } } - }, - "post": { - "description": "添加成员", + } + }, + "/scripts/{id}/access/{aid}": { + "put": { + "description": "更新访问控制", "consumes": [ "application/json" ], @@ -5135,9 +4942,9 @@ "application/json" ], "tags": [ - "script/group" + "script/access" ], - "summary": "添加成员", + "summary": "更新访问控制", "parameters": [ { "type": "integer", @@ -5147,7 +4954,7 @@ }, { "type": "integer", - "name": "gid", + "name": "aid", "in": "path", "required": true }, @@ -5155,7 +4962,7 @@ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.AddMemberRequest" + "$ref": "#/definitions/script.UpdateAccessRequest" } } ], @@ -5168,7 +4975,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.AddMemberResponse" + "$ref": "#/definitions/script.UpdateAccessResponse" }, "msg": { "type": "string" @@ -5183,11 +4990,9 @@ } } } - } - }, - "/scripts/{id}/group/{gid}/member/{mid}": { - "put": { - "description": "更新成员", + }, + "delete": { + "description": "删除访问控制", "consumes": [ "application/json" ], @@ -5195,16 +5000,10 @@ "application/json" ], "tags": [ - "script/group" + "script/access" ], - "summary": "更新成员", + "summary": "删除访问控制", "parameters": [ - { - "type": "integer", - "name": "mid", - "in": "path", - "required": true - }, { "type": "integer", "name": "id", @@ -5213,7 +5012,7 @@ }, { "type": "integer", - "name": "gid", + "name": "aid", "in": "path", "required": true }, @@ -5221,7 +5020,7 @@ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.UpdateMemberRequest" + "$ref": "#/definitions/script.DeleteAccessRequest" } } ], @@ -5234,7 +5033,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateMemberResponse" + "$ref": "#/definitions/script.DeleteAccessResponse" }, "msg": { "type": "string" @@ -5249,9 +5048,11 @@ } } } - }, - "delete": { - "description": "移除成员", + } + }, + "/scripts/{id}/archive": { + "put": { + "description": "归档脚本", "consumes": [ "application/json" ], @@ -5259,9 +5060,9 @@ "application/json" ], "tags": [ - "script/group" + "script" ], - "summary": "移除成员", + "summary": "归档脚本", "parameters": [ { "type": "integer", @@ -5269,23 +5070,11 @@ "in": "path", "required": true }, - { - "type": "integer", - "name": "gid", - "in": "path", - "required": true - }, - { - "type": "integer", - "name": "mid", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.RemoveMemberRequest" + "$ref": "#/definitions/script.ArchiveRequest" } } ], @@ -5298,7 +5087,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.RemoveMemberResponse" + "$ref": "#/definitions/script.ArchiveResponse" }, "msg": { "type": "string" @@ -5315,9 +5104,9 @@ } } }, - "/scripts/{id}/invite/code": { + "/scripts/{id}/audit-logs": { "get": { - "description": "邀请码列表", + "description": "单脚本日志(需要脚本 manage 权限)", "consumes": [ "application/json" ], @@ -5325,9 +5114,9 @@ "application/json" ], "tags": [ - "script/access_invite" + "audit/audit_log" ], - "summary": "邀请码列表", + "summary": "单脚本日志(需要脚本 manage 权限)", "parameters": [ { "type": "integer", @@ -5345,7 +5134,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.InviteCodeListResponse" + "$ref": "#/definitions/audit.ScriptListResponse" }, "msg": { "type": "string" @@ -5360,32 +5149,27 @@ } } } - }, - "post": { - "description": "创建邀请码", - "consumes": [ + } + }, + "/scripts/{id}/code": { + "get": { + "description": "获取脚本代码信息", + "consumes": [ "application/json" ], "produces": [ "application/json" ], "tags": [ - "script/access_invite" + "script" ], - "summary": "创建邀请码", + "summary": "获取脚本代码信息", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.CreateInviteCodeRequest" - } } ], "responses": { @@ -5397,7 +5181,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.CreateInviteCodeResponse" + "$ref": "#/definitions/script.CodeResponse" }, "msg": { "type": "string" @@ -5412,11 +5196,9 @@ } } } - } - }, - "/scripts/{id}/invite/code/{code_id}": { - "delete": { - "description": "删除邀请码", + }, + "put": { + "description": "更新脚本/库代码", "consumes": [ "application/json" ], @@ -5424,9 +5206,9 @@ "application/json" ], "tags": [ - "script/access_invite" + "script" ], - "summary": "删除邀请码", + "summary": "更新脚本/库代码", "parameters": [ { "type": "integer", @@ -5434,17 +5216,11 @@ "in": "path", "required": true }, - { - "type": "integer", - "name": "code_id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.DeleteInviteCodeRequest" + "$ref": "#/definitions/script.UpdateCodeRequest" } } ], @@ -5457,7 +5233,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.DeleteInviteCodeResponse" + "$ref": "#/definitions/script.UpdateCodeResponse" }, "msg": { "type": "string" @@ -5474,9 +5250,9 @@ } } }, - "/scripts/{id}/invite/code/{code_id}/audit": { + "/scripts/{id}/code/{codeId}": { "put": { - "description": "审核邀请码", + "description": "更新脚本设置", "consumes": [ "application/json" ], @@ -5484,9 +5260,9 @@ "application/json" ], "tags": [ - "script/access_invite" + "script" ], - "summary": "审核邀请码", + "summary": "更新脚本设置", "parameters": [ { "type": "integer", @@ -5496,7 +5272,7 @@ }, { "type": "integer", - "name": "code_id", + "name": "codeId", "in": "path", "required": true }, @@ -5504,7 +5280,7 @@ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.AuditInviteCodeRequest" + "$ref": "#/definitions/script.UpdateCodeSettingRequest" } } ], @@ -5517,7 +5293,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.AuditInviteCodeResponse" + "$ref": "#/definitions/script.UpdateCodeSettingResponse" }, "msg": { "type": "string" @@ -5532,11 +5308,9 @@ } } } - } - }, - "/scripts/{id}/invite/group/{gid}/code": { - "get": { - "description": "群组邀请码列表", + }, + "delete": { + "description": "删除脚本/库代码", "consumes": [ "application/json" ], @@ -5544,9 +5318,9 @@ "application/json" ], "tags": [ - "script/access_invite" + "script" ], - "summary": "群组邀请码列表", + "summary": "删除脚本/库代码", "parameters": [ { "type": "integer", @@ -5556,9 +5330,16 @@ }, { "type": "integer", - "name": "gid", + "name": "codeId", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.DeleteCodeRequest" + } } ], "responses": { @@ -5570,7 +5351,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.GroupInviteCodeListResponse" + "$ref": "#/definitions/script.DeleteCodeResponse" }, "msg": { "type": "string" @@ -5585,9 +5366,10 @@ } } } - }, - "post": { - "description": "创建群组邀请码", + } + }, + "/scripts/{id}/commentReply": { + "put": { "consumes": [ "application/json" ], @@ -5595,9 +5377,8 @@ "application/json" ], "tags": [ - "script/access_invite" + "script/score" ], - "summary": "创建群组邀请码", "parameters": [ { "type": "integer", @@ -5605,17 +5386,11 @@ "in": "path", "required": true }, - { - "type": "integer", - "name": "gid", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.CreateGroupInviteCodeRequest" + "$ref": "#/definitions/script.ReplyScoreRequest" } } ], @@ -5628,7 +5403,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.CreateGroupInviteCodeResponse" + "$ref": "#/definitions/script.ReplyScoreResponse" }, "msg": { "type": "string" @@ -5645,9 +5420,9 @@ } } }, - "/scripts/{id}/issues": { + "/scripts/{id}/group": { "get": { - "description": "获取脚本反馈列表", + "description": "群组列表", "consumes": [ "application/json" ], @@ -5655,9 +5430,9 @@ "application/json" ], "tags": [ - "issue" + "script/group" ], - "summary": "获取脚本反馈列表", + "summary": "群组列表", "parameters": [ { "type": "integer", @@ -5667,13 +5442,7 @@ }, { "type": "string", - "name": "keyword", - "in": "query" - }, - { - "type": "integer", - "description": "0:全部 1:待解决 3:已关闭", - "name": "status,default=0", + "name": "query", "in": "query" } ], @@ -5686,7 +5455,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.ListResponse" + "$ref": "#/definitions/script.GroupListResponse" }, "msg": { "type": "string" @@ -5703,7 +5472,7 @@ } }, "post": { - "description": "创建脚本反馈", + "description": "创建群组", "consumes": [ "application/json" ], @@ -5711,9 +5480,9 @@ "application/json" ], "tags": [ - "issue" + "script/group" ], - "summary": "创建脚本反馈", + "summary": "创建群组", "parameters": [ { "type": "integer", @@ -5725,7 +5494,7 @@ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/issue.CreateIssueRequest" + "$ref": "#/definitions/script.CreateGroupRequest" } } ], @@ -5738,7 +5507,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.CreateIssueResponse" + "$ref": "#/definitions/script.CreateGroupResponse" }, "msg": { "type": "string" @@ -5755,9 +5524,9 @@ } } }, - "/scripts/{id}/issues/{issueId}": { - "get": { - "description": "获取issue信息", + "/scripts/{id}/group/{gid}": { + "put": { + "description": "更新群组", "consumes": [ "application/json" ], @@ -5765,9 +5534,9 @@ "application/json" ], "tags": [ - "issue" + "script/group" ], - "summary": "获取issue信息", + "summary": "更新群组", "parameters": [ { "type": "integer", @@ -5777,9 +5546,16 @@ }, { "type": "integer", - "name": "issueId", + "name": "gid", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.UpdateGroupRequest" + } } ], "responses": { @@ -5791,7 +5567,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.GetIssueResponse" + "$ref": "#/definitions/script.UpdateGroupResponse" }, "msg": { "type": "string" @@ -5808,7 +5584,7 @@ } }, "delete": { - "description": "删除issue", + "description": "删除群组", "consumes": [ "application/json" ], @@ -5816,9 +5592,9 @@ "application/json" ], "tags": [ - "issue" + "script/group" ], - "summary": "删除issue", + "summary": "删除群组", "parameters": [ { "type": "integer", @@ -5828,7 +5604,7 @@ }, { "type": "integer", - "name": "issueId", + "name": "gid", "in": "path", "required": true }, @@ -5836,7 +5612,7 @@ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/issue.DeleteRequest" + "$ref": "#/definitions/script.DeleteGroupRequest" } } ], @@ -5849,7 +5625,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.DeleteResponse" + "$ref": "#/definitions/script.DeleteGroupResponse" }, "msg": { "type": "string" @@ -5866,9 +5642,9 @@ } } }, - "/scripts/{id}/issues/{issueId}/comment": { + "/scripts/{id}/group/{gid}/member": { "get": { - "description": "获取反馈评论列表", + "description": "群组成员列表", "consumes": [ "application/json" ], @@ -5876,9 +5652,9 @@ "application/json" ], "tags": [ - "issue/comment" + "script/group" ], - "summary": "获取反馈评论列表", + "summary": "群组成员列表", "parameters": [ { "type": "integer", @@ -5888,7 +5664,7 @@ }, { "type": "integer", - "name": "issueId", + "name": "gid", "in": "path", "required": true } @@ -5902,7 +5678,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.ListCommentResponse" + "$ref": "#/definitions/script.GroupMemberListResponse" }, "msg": { "type": "string" @@ -5919,7 +5695,7 @@ } }, "post": { - "description": "创建反馈评论", + "description": "添加成员", "consumes": [ "application/json" ], @@ -5927,9 +5703,9 @@ "application/json" ], "tags": [ - "issue/comment" + "script/group" ], - "summary": "创建反馈评论", + "summary": "添加成员", "parameters": [ { "type": "integer", @@ -5939,7 +5715,7 @@ }, { "type": "integer", - "name": "issueId", + "name": "gid", "in": "path", "required": true }, @@ -5947,7 +5723,7 @@ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/issue.CreateCommentRequest" + "$ref": "#/definitions/script.AddMemberRequest" } } ], @@ -5960,7 +5736,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.CreateCommentResponse" + "$ref": "#/definitions/script.AddMemberResponse" }, "msg": { "type": "string" @@ -5977,9 +5753,9 @@ } } }, - "/scripts/{id}/issues/{issueId}/comment/{commentId}": { - "delete": { - "description": "删除反馈评论", + "/scripts/{id}/group/{gid}/member/{mid}": { + "put": { + "description": "更新成员", "consumes": [ "application/json" ], @@ -5987,25 +5763,25 @@ "application/json" ], "tags": [ - "issue/comment" + "script/group" ], - "summary": "删除反馈评论", + "summary": "更新成员", "parameters": [ { "type": "integer", - "name": "id", + "name": "mid", "in": "path", "required": true }, { "type": "integer", - "name": "issueId", + "name": "id", "in": "path", "required": true }, { "type": "integer", - "name": "commentId", + "name": "gid", "in": "path", "required": true }, @@ -6013,7 +5789,7 @@ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/issue.DeleteCommentRequest" + "$ref": "#/definitions/script.UpdateMemberRequest" } } ], @@ -6026,7 +5802,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.DeleteCommentResponse" + "$ref": "#/definitions/script.UpdateMemberResponse" }, "msg": { "type": "string" @@ -6041,11 +5817,9 @@ } } } - } - }, - "/scripts/{id}/issues/{issueId}/labels": { - "put": { - "description": "更新issue标签", + }, + "delete": { + "description": "移除成员", "consumes": [ "application/json" ], @@ -6053,9 +5827,9 @@ "application/json" ], "tags": [ - "issue" + "script/group" ], - "summary": "更新issue标签", + "summary": "移除成员", "parameters": [ { "type": "integer", @@ -6065,7 +5839,13 @@ }, { "type": "integer", - "name": "issueId", + "name": "gid", + "in": "path", + "required": true + }, + { + "type": "integer", + "name": "mid", "in": "path", "required": true }, @@ -6073,7 +5853,7 @@ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/issue.UpdateLabelsRequest" + "$ref": "#/definitions/script.RemoveMemberRequest" } } ], @@ -6086,7 +5866,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.UpdateLabelsResponse" + "$ref": "#/definitions/script.RemoveMemberResponse" }, "msg": { "type": "string" @@ -6103,9 +5883,9 @@ } } }, - "/scripts/{id}/issues/{issueId}/open": { - "put": { - "description": "打开/关闭issue", + "/scripts/{id}/invite/code": { + "get": { + "description": "邀请码列表", "consumes": [ "application/json" ], @@ -6113,28 +5893,15 @@ "application/json" ], "tags": [ - "issue" + "script/access_invite" ], - "summary": "打开/关闭issue", + "summary": "邀请码列表", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "type": "integer", - "name": "issueId", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/issue.OpenRequest" - } } ], "responses": { @@ -6146,7 +5913,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.OpenResponse" + "$ref": "#/definitions/script.InviteCodeListResponse" }, "msg": { "type": "string" @@ -6161,11 +5928,9 @@ } } } - } - }, - "/scripts/{id}/issues/{issueId}/watch": { - "get": { - "description": "获取issue关注状态", + }, + "post": { + "description": "创建邀请码", "consumes": [ "application/json" ], @@ -6173,9 +5938,9 @@ "application/json" ], "tags": [ - "issue" + "script/access_invite" ], - "summary": "获取issue关注状态", + "summary": "创建邀请码", "parameters": [ { "type": "integer", @@ -6184,10 +5949,11 @@ "required": true }, { - "type": "integer", - "name": "issueId", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.CreateInviteCodeRequest" + } } ], "responses": { @@ -6199,7 +5965,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.GetWatchResponse" + "$ref": "#/definitions/script.CreateInviteCodeResponse" }, "msg": { "type": "string" @@ -6214,9 +5980,11 @@ } } } - }, - "put": { - "description": "关注issue", + } + }, + "/scripts/{id}/invite/code/{code_id}": { + "delete": { + "description": "删除邀请码", "consumes": [ "application/json" ], @@ -6224,9 +5992,9 @@ "application/json" ], "tags": [ - "issue" + "script/access_invite" ], - "summary": "关注issue", + "summary": "删除邀请码", "parameters": [ { "type": "integer", @@ -6236,7 +6004,7 @@ }, { "type": "integer", - "name": "issueId", + "name": "code_id", "in": "path", "required": true }, @@ -6244,7 +6012,7 @@ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/issue.WatchRequest" + "$ref": "#/definitions/script.DeleteInviteCodeRequest" } } ], @@ -6257,7 +6025,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/issue.WatchResponse" + "$ref": "#/definitions/script.DeleteInviteCodeResponse" }, "msg": { "type": "string" @@ -6274,9 +6042,9 @@ } } }, - "/scripts/{id}/public": { + "/scripts/{id}/invite/code/{code_id}/audit": { "put": { - "description": "更新脚本公开类型", + "description": "审核邀请码", "consumes": [ "application/json" ], @@ -6284,9 +6052,9 @@ "application/json" ], "tags": [ - "script" + "script/access_invite" ], - "summary": "更新脚本公开类型", + "summary": "审核邀请码", "parameters": [ { "type": "integer", @@ -6294,11 +6062,17 @@ "in": "path", "required": true }, + { + "type": "integer", + "name": "code_id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.UpdateScriptPublicRequest" + "$ref": "#/definitions/script.AuditInviteCodeRequest" } } ], @@ -6311,7 +6085,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateScriptPublicResponse" + "$ref": "#/definitions/script.AuditInviteCodeResponse" }, "msg": { "type": "string" @@ -6328,9 +6102,9 @@ } } }, - "/scripts/{id}/reports": { + "/scripts/{id}/invite/group/{gid}/code": { "get": { - "description": "获取脚本举报列表", + "description": "群组邀请码列表", "consumes": [ "application/json" ], @@ -6338,9 +6112,9 @@ "application/json" ], "tags": [ - "report" + "script/access_invite" ], - "summary": "获取脚本举报列表", + "summary": "群组邀请码列表", "parameters": [ { "type": "integer", @@ -6350,9 +6124,9 @@ }, { "type": "integer", - "description": "0:全部 1:待处理 3:已解决", - "name": "status,default=0", - "in": "query" + "name": "gid", + "in": "path", + "required": true } ], "responses": { @@ -6364,7 +6138,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/report.ListResponse" + "$ref": "#/definitions/script.GroupInviteCodeListResponse" }, "msg": { "type": "string" @@ -6381,7 +6155,7 @@ } }, "post": { - "description": "创建脚本举报", + "description": "创建群组邀请码", "consumes": [ "application/json" ], @@ -6389,9 +6163,9 @@ "application/json" ], "tags": [ - "report" + "script/access_invite" ], - "summary": "创建脚本举报", + "summary": "创建群组邀请码", "parameters": [ { "type": "integer", @@ -6399,11 +6173,17 @@ "in": "path", "required": true }, + { + "type": "integer", + "name": "gid", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/report.CreateReportRequest" + "$ref": "#/definitions/script.CreateGroupInviteCodeRequest" } } ], @@ -6416,7 +6196,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/report.CreateReportResponse" + "$ref": "#/definitions/script.CreateGroupInviteCodeResponse" }, "msg": { "type": "string" @@ -6433,9 +6213,9 @@ } } }, - "/scripts/{id}/reports/{reportId}": { + "/scripts/{id}/issues": { "get": { - "description": "获取举报详情", + "description": "获取脚本反馈列表", "consumes": [ "application/json" ], @@ -6443,9 +6223,9 @@ "application/json" ], "tags": [ - "report" + "issue" ], - "summary": "获取举报详情", + "summary": "获取脚本反馈列表", "parameters": [ { "type": "integer", @@ -6453,11 +6233,16 @@ "in": "path", "required": true }, + { + "type": "string", + "name": "keyword", + "in": "query" + }, { "type": "integer", - "name": "reportId", - "in": "path", - "required": true + "description": "0:全部 1:待解决 3:已关闭", + "name": "status,default=0", + "in": "query" } ], "responses": { @@ -6469,7 +6254,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/report.GetReportResponse" + "$ref": "#/definitions/issue.ListResponse" }, "msg": { "type": "string" @@ -6485,8 +6270,8 @@ } } }, - "delete": { - "description": "删除举报", + "post": { + "description": "创建脚本反馈", "consumes": [ "application/json" ], @@ -6494,9 +6279,9 @@ "application/json" ], "tags": [ - "report" + "issue" ], - "summary": "删除举报", + "summary": "创建脚本反馈", "parameters": [ { "type": "integer", @@ -6504,17 +6289,11 @@ "in": "path", "required": true }, - { - "type": "integer", - "name": "reportId", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/report.DeleteRequest" + "$ref": "#/definitions/issue.CreateIssueRequest" } } ], @@ -6527,7 +6306,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/report.DeleteResponse" + "$ref": "#/definitions/issue.CreateIssueResponse" }, "msg": { "type": "string" @@ -6544,9 +6323,9 @@ } } }, - "/scripts/{id}/reports/{reportId}/comments": { + "/scripts/{id}/issues/{issueId}": { "get": { - "description": "获取举报评论列表", + "description": "获取issue信息", "consumes": [ "application/json" ], @@ -6554,9 +6333,9 @@ "application/json" ], "tags": [ - "report/comment" + "issue" ], - "summary": "获取举报评论列表", + "summary": "获取issue信息", "parameters": [ { "type": "integer", @@ -6566,7 +6345,7 @@ }, { "type": "integer", - "name": "reportId", + "name": "issueId", "in": "path", "required": true } @@ -6580,7 +6359,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/report.ListCommentResponse" + "$ref": "#/definitions/issue.GetIssueResponse" }, "msg": { "type": "string" @@ -6596,8 +6375,8 @@ } } }, - "post": { - "description": "创建举报评论", + "delete": { + "description": "删除issue", "consumes": [ "application/json" ], @@ -6605,9 +6384,9 @@ "application/json" ], "tags": [ - "report/comment" + "issue" ], - "summary": "创建举报评论", + "summary": "删除issue", "parameters": [ { "type": "integer", @@ -6617,7 +6396,7 @@ }, { "type": "integer", - "name": "reportId", + "name": "issueId", "in": "path", "required": true }, @@ -6625,7 +6404,7 @@ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/report.CreateCommentRequest" + "$ref": "#/definitions/issue.DeleteRequest" } } ], @@ -6638,7 +6417,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/report.CreateCommentResponse" + "$ref": "#/definitions/issue.DeleteResponse" }, "msg": { "type": "string" @@ -6655,9 +6434,9 @@ } } }, - "/scripts/{id}/reports/{reportId}/comments/{commentId}": { - "delete": { - "description": "删除举报评论", + "/scripts/{id}/issues/{issueId}/comment": { + "get": { + "description": "获取反馈评论列表", "consumes": [ "application/json" ], @@ -6665,9 +6444,9 @@ "application/json" ], "tags": [ - "report/comment" + "issue/comment" ], - "summary": "删除举报评论", + "summary": "获取反馈评论列表", "parameters": [ { "type": "integer", @@ -6677,22 +6456,9 @@ }, { "type": "integer", - "name": "reportId", - "in": "path", - "required": true - }, - { - "type": "integer", - "name": "commentId", + "name": "issueId", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/report.DeleteCommentRequest" - } } ], "responses": { @@ -6704,7 +6470,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/report.DeleteCommentResponse" + "$ref": "#/definitions/issue.ListCommentResponse" }, "msg": { "type": "string" @@ -6719,11 +6485,9 @@ } } } - } - }, - "/scripts/{id}/reports/{reportId}/resolve": { - "put": { - "description": "解决/重新打开举报", + }, + "post": { + "description": "创建反馈评论", "consumes": [ "application/json" ], @@ -6731,9 +6495,9 @@ "application/json" ], "tags": [ - "report" + "issue/comment" ], - "summary": "解决/重新打开举报", + "summary": "创建反馈评论", "parameters": [ { "type": "integer", @@ -6743,7 +6507,7 @@ }, { "type": "integer", - "name": "reportId", + "name": "issueId", "in": "path", "required": true }, @@ -6751,7 +6515,7 @@ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/report.ResolveRequest" + "$ref": "#/definitions/issue.CreateCommentRequest" } } ], @@ -6764,7 +6528,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/report.ResolveResponse" + "$ref": "#/definitions/issue.CreateCommentResponse" }, "msg": { "type": "string" @@ -6781,9 +6545,9 @@ } } }, - "/scripts/{id}/score": { - "get": { - "description": "获取脚本评分列表", + "/scripts/{id}/issues/{issueId}/comment/{commentId}": { + "delete": { + "description": "删除反馈评论", "consumes": [ "application/json" ], @@ -6791,15 +6555,34 @@ "application/json" ], "tags": [ - "script/score" + "issue/comment" ], - "summary": "获取脚本评分列表", + "summary": "删除反馈评论", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true + }, + { + "type": "integer", + "name": "issueId", + "in": "path", + "required": true + }, + { + "type": "integer", + "name": "commentId", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/issue.DeleteCommentRequest" + } } ], "responses": { @@ -6811,7 +6594,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.ScoreListResponse" + "$ref": "#/definitions/issue.DeleteCommentResponse" }, "msg": { "type": "string" @@ -6826,9 +6609,11 @@ } } } - }, + } + }, + "/scripts/{id}/issues/{issueId}/labels": { "put": { - "description": "脚本评分", + "description": "更新issue标签", "consumes": [ "application/json" ], @@ -6836,9 +6621,9 @@ "application/json" ], "tags": [ - "script/score" + "issue" ], - "summary": "脚本评分", + "summary": "更新issue标签", "parameters": [ { "type": "integer", @@ -6846,11 +6631,17 @@ "in": "path", "required": true }, + { + "type": "integer", + "name": "issueId", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.PutScoreRequest" + "$ref": "#/definitions/issue.UpdateLabelsRequest" } } ], @@ -6863,7 +6654,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.PutScoreResponse" + "$ref": "#/definitions/issue.UpdateLabelsResponse" }, "msg": { "type": "string" @@ -6880,9 +6671,9 @@ } } }, - "/scripts/{id}/score/self": { - "get": { - "description": "用于获取自己对脚本的评价", + "/scripts/{id}/issues/{issueId}/open": { + "put": { + "description": "打开/关闭issue", "consumes": [ "application/json" ], @@ -6890,15 +6681,28 @@ "application/json" ], "tags": [ - "script/score" + "issue" ], - "summary": "用于获取自己对脚本的评价", + "summary": "打开/关闭issue", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true + }, + { + "type": "integer", + "name": "issueId", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/issue.OpenRequest" + } } ], "responses": { @@ -6910,7 +6714,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.SelfScoreResponse" + "$ref": "#/definitions/issue.OpenResponse" }, "msg": { "type": "string" @@ -6927,9 +6731,9 @@ } } }, - "/scripts/{id}/score/state": { + "/scripts/{id}/issues/{issueId}/watch": { "get": { - "description": "获取脚本评分状态", + "description": "获取issue关注状态", "consumes": [ "application/json" ], @@ -6937,15 +6741,21 @@ "application/json" ], "tags": [ - "script/score" + "issue" ], - "summary": "获取脚本评分状态", + "summary": "获取issue关注状态", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true + }, + { + "type": "integer", + "name": "issueId", + "in": "path", + "required": true } ], "responses": { @@ -6957,7 +6767,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.ScoreStateResponse" + "$ref": "#/definitions/issue.GetWatchResponse" }, "msg": { "type": "string" @@ -6972,11 +6782,9 @@ } } } - } - }, - "/scripts/{id}/score/{scoreId}": { - "delete": { - "description": "用于删除脚本的评价,注意,只有管理员才有权限删除评价", + }, + "put": { + "description": "关注issue", "consumes": [ "application/json" ], @@ -6984,9 +6792,9 @@ "application/json" ], "tags": [ - "script/score" + "issue" ], - "summary": "用于删除脚本的评价,注意,只有管理员才有权限删除评价", + "summary": "关注issue", "parameters": [ { "type": "integer", @@ -6996,7 +6804,7 @@ }, { "type": "integer", - "name": "scoreId", + "name": "issueId", "in": "path", "required": true }, @@ -7004,7 +6812,7 @@ "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.DelScoreRequest" + "$ref": "#/definitions/issue.WatchRequest" } } ], @@ -7017,7 +6825,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.DelScoreResponse" + "$ref": "#/definitions/issue.WatchResponse" }, "msg": { "type": "string" @@ -7034,9 +6842,9 @@ } } }, - "/scripts/{id}/setting": { - "get": { - "description": "获取脚本设置", + "/scripts/{id}/public": { + "put": { + "description": "更新脚本公开类型", "consumes": [ "application/json" ], @@ -7046,13 +6854,20 @@ "tags": [ "script" ], - "summary": "获取脚本设置", + "summary": "更新脚本公开类型", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.UpdateScriptPublicRequest" + } } ], "responses": { @@ -7064,7 +6879,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.GetSettingResponse" + "$ref": "#/definitions/script.UpdateScriptPublicResponse" }, "msg": { "type": "string" @@ -7079,9 +6894,11 @@ } } } - }, - "put": { - "description": "更新脚本设置", + } + }, + "/scripts/{id}/reports": { + "get": { + "description": "获取脚本举报列表", "consumes": [ "application/json" ], @@ -7089,9 +6906,9 @@ "application/json" ], "tags": [ - "script" + "report" ], - "summary": "更新脚本设置", + "summary": "获取脚本举报列表", "parameters": [ { "type": "integer", @@ -7100,11 +6917,10 @@ "required": true }, { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.UpdateSettingRequest" - } + "type": "integer", + "description": "0:全部 1:待处理 3:已解决", + "name": "status,default=0", + "in": "query" } ], "responses": { @@ -7116,7 +6932,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateSettingResponse" + "$ref": "#/definitions/report.ListResponse" }, "msg": { "type": "string" @@ -7131,11 +6947,9 @@ } } } - } - }, - "/scripts/{id}/state": { - "get": { - "description": "获取脚本状态,脚本关注等", + }, + "post": { + "description": "创建脚本举报", "consumes": [ "application/json" ], @@ -7143,15 +6957,22 @@ "application/json" ], "tags": [ - "script" + "report" ], - "summary": "获取脚本状态,脚本关注等", + "summary": "创建脚本举报", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/report.CreateReportRequest" + } } ], "responses": { @@ -7163,7 +6984,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.StateResponse" + "$ref": "#/definitions/report.CreateReportResponse" }, "msg": { "type": "string" @@ -7180,9 +7001,9 @@ } } }, - "/scripts/{id}/sync": { - "put": { - "description": "更新同步配置", + "/scripts/{id}/reports/{reportId}": { + "get": { + "description": "获取举报详情", "consumes": [ "application/json" ], @@ -7190,9 +7011,9 @@ "application/json" ], "tags": [ - "script" + "report" ], - "summary": "更新同步配置", + "summary": "获取举报详情", "parameters": [ { "type": "integer", @@ -7201,11 +7022,10 @@ "required": true }, { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.UpdateSyncSettingRequest" - } + "type": "integer", + "name": "reportId", + "in": "path", + "required": true } ], "responses": { @@ -7217,7 +7037,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateSyncSettingResponse" + "$ref": "#/definitions/report.GetReportResponse" }, "msg": { "type": "string" @@ -7232,11 +7052,9 @@ } } } - } - }, - "/scripts/{id}/unwell": { - "put": { - "description": "更新脚本不适内容", + }, + "delete": { + "description": "删除举报", "consumes": [ "application/json" ], @@ -7244,9 +7062,9 @@ "application/json" ], "tags": [ - "script" + "report" ], - "summary": "更新脚本不适内容", + "summary": "删除举报", "parameters": [ { "type": "integer", @@ -7254,11 +7072,17 @@ "in": "path", "required": true }, + { + "type": "integer", + "name": "reportId", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.UpdateScriptUnwellRequest" + "$ref": "#/definitions/report.DeleteRequest" } } ], @@ -7271,7 +7095,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.UpdateScriptUnwellResponse" + "$ref": "#/definitions/report.DeleteResponse" }, "msg": { "type": "string" @@ -7288,9 +7112,9 @@ } } }, - "/scripts/{id}/versions": { + "/scripts/{id}/reports/{reportId}/comments": { "get": { - "description": "获取版本列表", + "description": "获取举报评论列表", "consumes": [ "application/json" ], @@ -7298,15 +7122,21 @@ "application/json" ], "tags": [ - "script" + "report/comment" ], - "summary": "获取版本列表", + "summary": "获取举报评论列表", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true + }, + { + "type": "integer", + "name": "reportId", + "in": "path", + "required": true } ], "responses": { @@ -7318,7 +7148,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.VersionListResponse" + "$ref": "#/definitions/report.ListCommentResponse" }, "msg": { "type": "string" @@ -7333,11 +7163,9 @@ } } } - } - }, - "/scripts/{id}/versions/stat": { - "get": { - "description": "获取脚本版本统计信息", + }, + "post": { + "description": "创建举报评论", "consumes": [ "application/json" ], @@ -7345,15 +7173,28 @@ "application/json" ], "tags": [ - "script" + "report/comment" ], - "summary": "获取脚本版本统计信息", + "summary": "创建举报评论", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true + }, + { + "type": "integer", + "name": "reportId", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/report.CreateCommentRequest" + } } ], "responses": { @@ -7365,7 +7206,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.VersionStatResponse" + "$ref": "#/definitions/report.CreateCommentResponse" }, "msg": { "type": "string" @@ -7382,9 +7223,9 @@ } } }, - "/scripts/{id}/versions/{version}/code": { - "get": { - "description": "获取指定版本代码", + "/scripts/{id}/reports/{reportId}/comments/{commentId}": { + "delete": { + "description": "删除举报评论", "consumes": [ "application/json" ], @@ -7392,9 +7233,9 @@ "application/json" ], "tags": [ - "script" + "report/comment" ], - "summary": "获取指定版本代码", + "summary": "删除举报评论", "parameters": [ { "type": "integer", @@ -7403,10 +7244,23 @@ "required": true }, { - "type": "string", - "name": "version", + "type": "integer", + "name": "reportId", + "in": "path", + "required": true + }, + { + "type": "integer", + "name": "commentId", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/report.DeleteCommentRequest" + } } ], "responses": { @@ -7418,7 +7272,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.VersionCodeResponse" + "$ref": "#/definitions/report.DeleteCommentResponse" }, "msg": { "type": "string" @@ -7435,9 +7289,9 @@ } } }, - "/scripts/{id}/visit": { - "post": { - "description": "记录脚本访问统计", + "/scripts/{id}/reports/{reportId}/resolve": { + "put": { + "description": "解决/重新打开举报", "consumes": [ "application/json" ], @@ -7445,9 +7299,9 @@ "application/json" ], "tags": [ - "script" + "report" ], - "summary": "记录脚本访问统计", + "summary": "解决/重新打开举报", "parameters": [ { "type": "integer", @@ -7455,11 +7309,17 @@ "in": "path", "required": true }, + { + "type": "integer", + "name": "reportId", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.RecordVisitRequest" + "$ref": "#/definitions/report.ResolveRequest" } } ], @@ -7472,7 +7332,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.RecordVisitResponse" + "$ref": "#/definitions/report.ResolveResponse" }, "msg": { "type": "string" @@ -7489,9 +7349,9 @@ } } }, - "/scripts/{id}/watch": { - "post": { - "description": "关注脚本", + "/scripts/{id}/score": { + "get": { + "description": "获取脚本评分列表", "consumes": [ "application/json" ], @@ -7499,22 +7359,15 @@ "application/json" ], "tags": [ - "script" + "script/score" ], - "summary": "关注脚本", + "summary": "获取脚本评分列表", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/script.WatchRequest" - } } ], "responses": { @@ -7526,7 +7379,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.WatchResponse" + "$ref": "#/definitions/script.ScoreListResponse" }, "msg": { "type": "string" @@ -7541,11 +7394,9 @@ } } } - } - }, - "/users": { - "get": { - "description": "获取当前登录的用户信息", + }, + "put": { + "description": "脚本评分", "consumes": [ "application/json" ], @@ -7553,9 +7404,24 @@ "application/json" ], "tags": [ - "user" + "script/score" + ], + "summary": "脚本评分", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.PutScoreRequest" + } + } ], - "summary": "获取当前登录的用户信息", "responses": { "200": { "description": "OK", @@ -7565,7 +7431,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.CurrentUserResponse" + "$ref": "#/definitions/script.PutScoreResponse" }, "msg": { "type": "string" @@ -7582,9 +7448,9 @@ } } }, - "/users/avatar": { - "put": { - "description": "更新用户头像", + "/scripts/{id}/score/self": { + "get": { + "description": "用于获取自己对脚本的评价", "consumes": [ "application/json" ], @@ -7592,16 +7458,15 @@ "application/json" ], "tags": [ - "user" + "script/score" ], - "summary": "更新用户头像", + "summary": "用于获取自己对脚本的评价", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/user.UpdateUserAvatarRequest" - } + "type": "integer", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -7613,7 +7478,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.UpdateUserAvatarResponse" + "$ref": "#/definitions/script.SelfScoreResponse" }, "msg": { "type": "string" @@ -7630,9 +7495,9 @@ } } }, - "/users/config": { + "/scripts/{id}/score/state": { "get": { - "description": "获取用户配置", + "description": "获取脚本评分状态", "consumes": [ "application/json" ], @@ -7640,9 +7505,17 @@ "application/json" ], "tags": [ - "user" + "script/score" + ], + "summary": "获取脚本评分状态", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + } ], - "summary": "获取用户配置", "responses": { "200": { "description": "OK", @@ -7652,7 +7525,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.GetConfigResponse" + "$ref": "#/definitions/script.ScoreStateResponse" }, "msg": { "type": "string" @@ -7667,9 +7540,11 @@ } } } - }, - "put": { - "description": "更新用户配置", + } + }, + "/scripts/{id}/score/{scoreId}": { + "delete": { + "description": "用于删除脚本的评价,注意,只有管理员才有权限删除评价", "consumes": [ "application/json" ], @@ -7677,15 +7552,27 @@ "application/json" ], "tags": [ - "user" + "script/score" ], - "summary": "更新用户配置", + "summary": "用于删除脚本的评价,注意,只有管理员才有权限删除评价", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "name": "scoreId", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/user.UpdateConfigRequest" + "$ref": "#/definitions/script.DelScoreRequest" } } ], @@ -7698,7 +7585,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.UpdateConfigResponse" + "$ref": "#/definitions/script.DelScoreResponse" }, "msg": { "type": "string" @@ -7715,9 +7602,9 @@ } } }, - "/users/deactivate": { - "post": { - "description": "确认注销", + "/scripts/{id}/setting": { + "get": { + "description": "获取脚本设置", "consumes": [ "application/json" ], @@ -7725,16 +7612,15 @@ "application/json" ], "tags": [ - "user" + "script" ], - "summary": "确认注销", + "summary": "获取脚本设置", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/user.DeactivateRequest" - } + "type": "integer", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -7746,7 +7632,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.DeactivateResponse" + "$ref": "#/definitions/script.GetSettingResponse" }, "msg": { "type": "string" @@ -7762,8 +7648,8 @@ } } }, - "delete": { - "description": "取消注销", + "put": { + "description": "更新脚本设置", "consumes": [ "application/json" ], @@ -7771,15 +7657,21 @@ "application/json" ], "tags": [ - "user" + "script" ], - "summary": "取消注销", + "summary": "更新脚本设置", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/user.CancelDeactivateRequest" + "$ref": "#/definitions/script.UpdateSettingRequest" } } ], @@ -7792,7 +7684,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.CancelDeactivateResponse" + "$ref": "#/definitions/script.UpdateSettingResponse" }, "msg": { "type": "string" @@ -7809,9 +7701,9 @@ } } }, - "/users/deactivate/code": { - "post": { - "description": "发送注销验证码", + "/scripts/{id}/state": { + "get": { + "description": "获取脚本状态,脚本关注等", "consumes": [ "application/json" ], @@ -7819,16 +7711,15 @@ "application/json" ], "tags": [ - "user" + "script" ], - "summary": "发送注销验证码", + "summary": "获取脚本状态,脚本关注等", "parameters": [ { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/user.SendDeactivateCodeRequest" - } + "type": "integer", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -7840,7 +7731,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.SendDeactivateCodeResponse" + "$ref": "#/definitions/script.StateResponse" }, "msg": { "type": "string" @@ -7857,9 +7748,9 @@ } } }, - "/users/detail": { + "/scripts/{id}/sync": { "put": { - "description": "更新用户信息", + "description": "更新同步配置", "consumes": [ "application/json" ], @@ -7867,15 +7758,21 @@ "application/json" ], "tags": [ - "user" + "script" ], - "summary": "更新用户信息", + "summary": "更新同步配置", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/user.UpdateUserDetailRequest" + "$ref": "#/definitions/script.UpdateSyncSettingRequest" } } ], @@ -7888,7 +7785,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.UpdateUserDetailResponse" + "$ref": "#/definitions/script.UpdateSyncSettingResponse" }, "msg": { "type": "string" @@ -7905,8 +7802,9 @@ } } }, - "/users/logout": { - "get": { + "/scripts/{id}/unwell": { + "put": { + "description": "更新脚本不适内容", "consumes": [ "application/json" ], @@ -7914,7 +7812,23 @@ "application/json" ], "tags": [ - "user" + "script" + ], + "summary": "更新脚本不适内容", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.UpdateScriptUnwellRequest" + } + } ], "responses": { "200": { @@ -7925,7 +7839,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.LogoutResponse" + "$ref": "#/definitions/script.UpdateScriptUnwellResponse" }, "msg": { "type": "string" @@ -7942,9 +7856,9 @@ } } }, - "/users/oauth/bind/{id}": { - "delete": { - "description": "DELETE /users/oauth/bind/:id — 解绑 OAuth", + "/scripts/{id}/versions": { + "get": { + "description": "获取版本列表", "consumes": [ "application/json" ], @@ -7952,22 +7866,15 @@ "application/json" ], "tags": [ - "auth/oidc_login" + "script" ], - "summary": "DELETE /users/oauth/bind/:id — 解绑 OAuth", + "summary": "获取版本列表", "parameters": [ { "type": "integer", "name": "id", "in": "path", "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/auth.UserOAuthUnbindRequest" - } } ], "responses": { @@ -7979,7 +7886,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.UserOAuthUnbindResponse" + "$ref": "#/definitions/script.VersionListResponse" }, "msg": { "type": "string" @@ -7996,9 +7903,9 @@ } } }, - "/users/oauth/bindlist": { + "/scripts/{id}/versions/stat": { "get": { - "description": "GET /users/oauth/bindlist — 获取当前用户的 OAuth 绑定列表", + "description": "获取脚本版本统计信息", "consumes": [ "application/json" ], @@ -8006,9 +7913,17 @@ "application/json" ], "tags": [ - "auth/oidc_login" + "script" + ], + "summary": "获取脚本版本统计信息", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + } ], - "summary": "GET /users/oauth/bindlist — 获取当前用户的 OAuth 绑定列表", "responses": { "200": { "description": "OK", @@ -8018,7 +7933,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/auth.UserOAuthListResponse" + "$ref": "#/definitions/script.VersionStatResponse" }, "msg": { "type": "string" @@ -8035,9 +7950,9 @@ } } }, - "/users/password": { - "put": { - "description": "修改密码", + "/scripts/{id}/versions/{version}/code": { + "get": { + "description": "获取指定版本代码", "consumes": [ "application/json" ], @@ -8045,15 +7960,74 @@ "application/json" ], "tags": [ - "user" + "script" ], - "summary": "修改密码", + "summary": "获取指定版本代码", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "version", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/script.VersionCodeResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/scripts/{id}/visit": { + "post": { + "description": "记录脚本访问统计", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "script" + ], + "summary": "记录脚本访问统计", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/user.ChangePasswordRequest" + "$ref": "#/definitions/script.RecordVisitRequest" } } ], @@ -8066,7 +8040,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.ChangePasswordResponse" + "$ref": "#/definitions/script.RecordVisitResponse" }, "msg": { "type": "string" @@ -8083,9 +8057,9 @@ } } }, - "/users/refresh-token": { + "/scripts/{id}/watch": { "post": { - "description": "刷新用户token", + "description": "关注脚本", "consumes": [ "application/json" ], @@ -8093,15 +8067,21 @@ "application/json" ], "tags": [ - "user" + "script" ], - "summary": "刷新用户token", + "summary": "关注脚本", "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/user.RefreshTokenRequest" + "$ref": "#/definitions/script.WatchRequest" } } ], @@ -8114,7 +8094,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.RefreshTokenResponse" + "$ref": "#/definitions/script.WatchResponse" }, "msg": { "type": "string" @@ -8131,9 +8111,8 @@ } } }, - "/users/search": { + "/similarity/pair/{id}": { "get": { - "description": "搜索用户", "consumes": [ "application/json" ], @@ -8141,14 +8120,14 @@ "application/json" ], "tags": [ - "user" + "similarity" ], - "summary": "搜索用户", "parameters": [ { - "type": "string", - "name": "query", - "in": "query" + "type": "integer", + "name": "id", + "in": "path", + "required": true } ], "responses": { @@ -8160,7 +8139,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.SearchResponse" + "$ref": "#/definitions/similarity.GetEvidencePairResponse" }, "msg": { "type": "string" @@ -8177,9 +8156,9 @@ } } }, - "/users/webhook": { + "/users": { "get": { - "description": "获取webhook配置", + "description": "获取当前登录的用户信息", "consumes": [ "application/json" ], @@ -8189,7 +8168,7 @@ "tags": [ "user" ], - "summary": "获取webhook配置", + "summary": "获取当前登录的用户信息", "responses": { "200": { "description": "OK", @@ -8199,7 +8178,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.GetWebhookResponse" + "$ref": "#/definitions/user.CurrentUserResponse" }, "msg": { "type": "string" @@ -8214,9 +8193,11 @@ } } } - }, + } + }, + "/users/avatar": { "put": { - "description": "刷新webhook配置", + "description": "更新用户头像", "consumes": [ "application/json" ], @@ -8226,13 +8207,13 @@ "tags": [ "user" ], - "summary": "刷新webhook配置", + "summary": "更新用户头像", "parameters": [ { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/user.RefreshWebhookRequest" + "$ref": "#/definitions/user.UpdateUserAvatarRequest" } } ], @@ -8245,7 +8226,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.RefreshWebhookResponse" + "$ref": "#/definitions/user.UpdateUserAvatarResponse" }, "msg": { "type": "string" @@ -8262,9 +8243,9 @@ } } }, - "/users/{user_id}/detail": { + "/users/config": { "get": { - "description": "获取用户详细信息", + "description": "获取用户配置", "consumes": [ "application/json" ], @@ -8274,15 +8255,7 @@ "tags": [ "user" ], - "summary": "获取用户详细信息", - "parameters": [ - { - "type": "integer", - "name": "user_id", - "in": "path", - "required": true - } - ], + "summary": "获取用户配置", "responses": { "200": { "description": "OK", @@ -8292,7 +8265,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.GetUserDetailResponse" + "$ref": "#/definitions/user.GetConfigResponse" }, "msg": { "type": "string" @@ -8307,11 +8280,9 @@ } } } - } - }, - "/users/{user_id}/follow": { - "get": { - "description": "获取用户关注信息", + }, + "put": { + "description": "更新用户配置", "consumes": [ "application/json" ], @@ -8321,13 +8292,14 @@ "tags": [ "user" ], - "summary": "获取用户关注信息", + "summary": "更新用户配置", "parameters": [ { - "type": "integer", - "name": "user_id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.UpdateConfigRequest" + } } ], "responses": { @@ -8339,7 +8311,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.GetFollowResponse" + "$ref": "#/definitions/user.UpdateConfigResponse" }, "msg": { "type": "string" @@ -8354,9 +8326,11 @@ } } } - }, + } + }, + "/users/deactivate": { "post": { - "description": "关注用户", + "description": "确认注销", "consumes": [ "application/json" ], @@ -8366,19 +8340,13 @@ "tags": [ "user" ], - "summary": "关注用户", + "summary": "确认注销", "parameters": [ - { - "type": "integer", - "name": "user_id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/user.FollowRequest" + "$ref": "#/definitions/user.DeactivateRequest" } } ], @@ -8391,7 +8359,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.FollowResponse" + "$ref": "#/definitions/user.DeactivateResponse" }, "msg": { "type": "string" @@ -8406,11 +8374,9 @@ } } } - } - }, - "/users/{user_id}/info": { - "get": { - "description": "获取指定用户信息", + }, + "delete": { + "description": "取消注销", "consumes": [ "application/json" ], @@ -8420,13 +8386,14 @@ "tags": [ "user" ], - "summary": "获取指定用户信息", + "summary": "取消注销", "parameters": [ { - "type": "integer", - "name": "user_id", - "in": "path", - "required": true + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.CancelDeactivateRequest" + } } ], "responses": { @@ -8438,7 +8405,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.InfoResponse" + "$ref": "#/definitions/user.CancelDeactivateResponse" }, "msg": { "type": "string" @@ -8455,9 +8422,9 @@ } } }, - "/users/{user_id}/scripts": { - "get": { - "description": "用户脚本列表", + "/users/deactivate/code": { + "post": { + "description": "发送注销验证码", "consumes": [ "application/json" ], @@ -8467,29 +8434,14 @@ "tags": [ "user" ], - "summary": "用户脚本列表", + "summary": "发送注销验证码", "parameters": [ { - "type": "integer", - "name": "user_id", - "in": "path", - "required": true - }, - { - "type": "string", - "name": "keyword", - "in": "query" - }, - { - "type": "integer", - "description": "0:全部 1: 脚本 2: 库 3: 后台脚本 4: 定时脚本", - "name": "script_type,default=0", - "in": "query" - }, - { - "type": "string", - "name": "sort,default=today_download", - "in": "query" + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.SendDeactivateCodeRequest" + } } ], "responses": { @@ -8501,7 +8453,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/user.ScriptResponse" + "$ref": "#/definitions/user.SendDeactivateCodeResponse" }, "msg": { "type": "string" @@ -8518,9 +8470,9 @@ } } }, - "/webhook/{user_id}": { - "post": { - "description": "处理webhook请求", + "/users/detail": { + "put": { + "description": "更新用户信息", "consumes": [ "application/json" ], @@ -8528,21 +8480,15 @@ "application/json" ], "tags": [ - "script" + "user" ], - "summary": "处理webhook请求", + "summary": "更新用户信息", "parameters": [ - { - "type": "integer", - "name": "user_id", - "in": "path", - "required": true - }, { "name": "body", "in": "body", "schema": { - "$ref": "#/definitions/script.WebhookRequest" + "$ref": "#/definitions/user.UpdateUserDetailRequest" } } ], @@ -8555,7 +8501,7 @@ "type": "integer" }, "data": { - "$ref": "#/definitions/script.WebhookResponse" + "$ref": "#/definitions/user.UpdateUserDetailResponse" }, "msg": { "type": "string" @@ -8571,56 +8517,1203 @@ } } } - } - }, - "definitions": { - "BadRequest": { - "type": "object", - "properties": { - "code": { - "description": "错误码", - "type": "integer", - "format": "int32" - }, - "msg": { + }, + "/users/email/code": { + "post": { + "description": "发送邮箱验证码", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "发送邮箱验证码", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.SendEmailCodeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.SendEmailCodeResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/logout": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.LogoutResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/oauth/bind/{id}": { + "delete": { + "description": "DELETE /users/oauth/bind/:id — 解绑 OAuth", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth/oidc_login" + ], + "summary": "DELETE /users/oauth/bind/:id — 解绑 OAuth", + "parameters": [ + { + "type": "integer", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/auth.UserOAuthUnbindRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/auth.UserOAuthUnbindResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/oauth/bindlist": { + "get": { + "description": "GET /users/oauth/bindlist — 获取当前用户的 OAuth 绑定列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth/oidc_login" + ], + "summary": "GET /users/oauth/bindlist — 获取当前用户的 OAuth 绑定列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/auth.UserOAuthListResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/password": { + "put": { + "description": "修改密码", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "修改密码", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.ChangePasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.ChangePasswordResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/refresh-token": { + "post": { + "description": "刷新用户token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "刷新用户token", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.RefreshTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.RefreshTokenResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/search": { + "get": { + "description": "搜索用户", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "搜索用户", + "parameters": [ + { + "type": "string", + "name": "query", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.SearchResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/webhook": { + "get": { + "description": "获取webhook配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取webhook配置", + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.GetWebhookResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + }, + "put": { + "description": "刷新webhook配置", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "刷新webhook配置", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.RefreshWebhookRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.RefreshWebhookResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/{user_id}/detail": { + "get": { + "description": "获取用户详细信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取用户详细信息", + "parameters": [ + { + "type": "integer", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.GetUserDetailResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/{user_id}/follow": { + "get": { + "description": "获取用户关注信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取用户关注信息", + "parameters": [ + { + "type": "integer", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.GetFollowResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + }, + "post": { + "description": "关注用户", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "关注用户", + "parameters": [ + { + "type": "integer", + "name": "user_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/user.FollowRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.FollowResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/{user_id}/info": { + "get": { + "description": "获取指定用户信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取指定用户信息", + "parameters": [ + { + "type": "integer", + "name": "user_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.InfoResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/users/{user_id}/scripts": { + "get": { + "description": "用户脚本列表", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户脚本列表", + "parameters": [ + { + "type": "integer", + "name": "user_id", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "keyword", + "in": "query" + }, + { + "type": "integer", + "description": "0:全部 1: 脚本 2: 库 3: 后台脚本 4: 定时脚本", + "name": "script_type,default=0", + "in": "query" + }, + { + "type": "string", + "name": "sort,default=today_download", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/user.ScriptResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + }, + "/webhook/{user_id}": { + "post": { + "description": "处理webhook请求", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "script" + ], + "summary": "处理webhook请求", + "parameters": [ + { + "type": "integer", + "name": "user_id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/script.WebhookRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "properties": { + "code": { + "type": "integer" + }, + "data": { + "$ref": "#/definitions/script.WebhookResponse" + }, + "msg": { + "type": "string" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/BadRequest" + } + } + } + } + } + }, + "definitions": { + "BadRequest": { + "type": "object", + "properties": { + "code": { + "description": "错误码", + "type": "integer", + "format": "int32" + }, + "msg": { "description": "错误信息", "type": "string" } } }, - "admin.AdminDeleteFeedbackRequest": { + "admin.AdminDeleteFeedbackRequest": { + "type": "object" + }, + "admin.AdminDeleteFeedbackResponse": { + "type": "object" + }, + "admin.AdminReportItem": { + "type": "object", + "properties": { + "comment_count": { + "type": "integer" + }, + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "reason": { + "type": "string" + }, + "script_id": { + "type": "integer" + }, + "script_name": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "updatetime": { + "type": "integer" + }, + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "admin.AdminRestoreScriptRequest": { + "type": "object" + }, + "admin.AdminRestoreScriptResponse": { + "type": "object" + }, + "admin.AdminUpdateScriptVisibilityRequest": { + "type": "object", + "properties": { + "public": { + "type": "integer" + }, + "unwell": { + "type": "integer" + } + } + }, + "admin.AdminUpdateScriptVisibilityResponse": { + "type": "object" + }, + "admin.CreateOAuthAppRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "redirect_uri": { + "type": "string" + } + } + }, + "admin.CreateOAuthAppResponse": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "description": "#nosec G117 -- API 响应字段,非硬编码密码", + "type": "string" + }, + "id": { + "type": "integer" + } + } + }, + "admin.CreateOIDCProviderRequest": { + "type": "object", + "properties": { + "auth_url": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "client_secret": { + "description": "#nosec G117 -- API 请求字段,非硬编码密码", + "type": "string" + }, + "display_order": { + "type": "integer" + }, + "icon": { + "type": "string" + }, + "issuer_url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "token_url": { + "type": "string" + }, + "type": { + "type": "string" + }, + "userinfo_url": { + "type": "string" + } + } + }, + "admin.CreateOIDCProviderResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, + "admin.DeleteOAuthAppRequest": { + "type": "object" + }, + "admin.DeleteOAuthAppResponse": { + "type": "object" + }, + "admin.DeleteOIDCProviderRequest": { + "type": "object" + }, + "admin.DeleteOIDCProviderResponse": { + "type": "object" + }, + "admin.DiscoverOIDCConfigRequest": { + "type": "object", + "properties": { + "issuer_url": { + "type": "string" + } + } + }, + "admin.DiscoverOIDCConfigResponse": { + "type": "object", + "properties": { + "authorization_endpoint": { + "type": "string" + }, + "issuer": { + "type": "string" + }, + "jwks_uri": { + "type": "string" + }, + "scopes_supported": { + "description": "逗号分隔", + "type": "string" + }, + "token_endpoint": { + "type": "string" + }, + "userinfo_endpoint": { + "type": "string" + } + } + }, + "admin.FeedbackItem": { + "type": "object", + "properties": { + "client_ip": { + "type": "string" + }, + "content": { + "type": "string" + }, + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "reason": { + "type": "string" + } + } + }, + "admin.GetMigrateAvatarStatusResponse": { + "type": "object", + "properties": { + "failed": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "migrated": { + "type": "integer" + }, + "running": { + "type": "boolean" + }, + "skipped": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "admin.GetSystemConfigsResponse": { + "type": "object", + "properties": { + "configs": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.SystemConfigItem" + } + } + } + }, + "admin.ListFeedbackResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/admin.FeedbackItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "admin.ListOAuthAppsResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/admin.OAuthAppItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "admin.ListOIDCProvidersResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/admin.OIDCProviderItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "admin.ListReportsResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/admin.AdminReportItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "admin.ListScoresResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/admin.ScoreItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "admin.ListScriptsResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/admin.ScriptItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "admin.ListUsersResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/admin.UserItem" + } + }, + "total": { + "type": "integer" + } + } + }, + "admin.MigrateAvatarRequest": { "type": "object" }, - "admin.AdminDeleteFeedbackResponse": { + "admin.MigrateAvatarResponse": { "type": "object" }, - "admin.AdminReportItem": { + "admin.OAuthAppItem": { "type": "object", "properties": { - "comment_count": { - "type": "integer" + "client_id": { + "type": "string" }, "createtime": { "type": "integer" }, + "description": { + "type": "string" + }, "id": { "type": "integer" }, - "reason": { + "name": { "type": "string" }, - "script_id": { + "redirect_uri": { + "type": "string" + }, + "status": { "type": "integer" }, - "script_name": { + "updatetime": { + "type": "integer" + } + } + }, + "admin.OIDCProviderItem": { + "type": "object", + "properties": { + "auth_url": { + "type": "string" + }, + "client_id": { + "type": "string" + }, + "client_secret": { + "description": "#nosec G117 -- API 响应字段,掩码处理后返回", + "type": "string" + }, + "createtime": { + "type": "integer" + }, + "display_order": { + "type": "integer" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "issuer_url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "scopes": { "type": "string" }, "status": { "type": "integer" }, + "token_url": { + "type": "string" + }, + "type": { + "type": "string" + }, "updatetime": { "type": "integer" }, + "userinfo_url": { + "type": "string" + } + } + }, + "admin.ResetOAuthAppSecretRequest": { + "type": "object" + }, + "admin.ResetOAuthAppSecretResponse": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "description": "#nosec G117 -- API 响应字段,非硬编码密码", + "type": "string" + }, + "id": { + "type": "integer" + } + } + }, + "admin.ScoreItem": { + "type": "object", + "properties": { + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "score": { + "type": "integer" + }, + "script_id": { + "type": "integer" + }, + "script_name": { + "type": "string" + }, "user_id": { "type": "integer" }, @@ -8629,56 +9722,73 @@ } } }, - "admin.AdminRestoreScriptRequest": { - "type": "object" - }, - "admin.AdminRestoreScriptResponse": { - "type": "object" - }, - "admin.AdminUpdateScriptVisibilityRequest": { + "admin.ScriptItem": { "type": "object", "properties": { + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, "public": { "type": "integer" }, + "status": { + "type": "integer" + }, + "type": { + "type": "integer" + }, "unwell": { "type": "integer" + }, + "updatetime": { + "type": "integer" + }, + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" } } }, - "admin.AdminUpdateScriptVisibilityResponse": { - "type": "object" - }, - "admin.CreateOAuthAppRequest": { + "admin.SystemConfigItem": { "type": "object", "properties": { - "description": { - "type": "string" - }, - "name": { + "key": { "type": "string" }, - "redirect_uri": { + "value": { "type": "string" } } }, - "admin.CreateOAuthAppResponse": { + "admin.UpdateOAuthAppRequest": { "type": "object", "properties": { - "client_id": { + "description": { "type": "string" }, - "client_secret": { - "description": "#nosec G117 -- API 响应字段,非硬编码密码", + "name": { "type": "string" }, - "id": { + "redirect_uri": { + "type": "string" + }, + "status": { "type": "integer" } } }, - "admin.CreateOIDCProviderRequest": { + "admin.UpdateOAuthAppResponse": { + "type": "object" + }, + "admin.UpdateOIDCProviderRequest": { "type": "object", "properties": { "auth_url": { @@ -8706,6 +9816,9 @@ "scopes": { "type": "string" }, + "status": { + "type": "integer" + }, "token_url": { "type": "string" }, @@ -8717,65 +9830,98 @@ } } }, - "admin.CreateOIDCProviderResponse": { + "admin.UpdateOIDCProviderResponse": { + "type": "object" + }, + "admin.UpdateSystemConfigsRequest": { "type": "object", "properties": { - "id": { - "type": "integer" + "configs": { + "type": "array", + "items": { + "$ref": "#/definitions/admin.SystemConfigItem" + } } } }, - "admin.DeleteOAuthAppRequest": { - "type": "object" - }, - "admin.DeleteOAuthAppResponse": { + "admin.UpdateSystemConfigsResponse": { "type": "object" }, - "admin.DeleteOIDCProviderRequest": { - "type": "object" + "admin.UpdateUserAdminLevelRequest": { + "type": "object", + "properties": { + "admin_level": { + "type": "integer" + } + } }, - "admin.DeleteOIDCProviderResponse": { + "admin.UpdateUserAdminLevelResponse": { "type": "object" }, - "admin.DiscoverOIDCConfigRequest": { + "admin.UpdateUserStatusRequest": { "type": "object", "properties": { - "issuer_url": { + "clean_scores": { + "description": "是否清理用户评分", + "type": "boolean" + }, + "clean_scripts": { + "description": "是否清理用户脚本", + "type": "boolean" + }, + "expire_at": { + "description": "封禁到期时间(unix timestamp), 0=永久", + "type": "integer" + }, + "reason": { + "description": "封禁理由", "type": "string" + }, + "status": { + "type": "integer" } } }, - "admin.DiscoverOIDCConfigResponse": { + "admin.UpdateUserStatusResponse": { + "type": "object" + }, + "admin.UserItem": { "type": "object", "properties": { - "authorization_endpoint": { - "type": "string" + "admin_level": { + "type": "integer" }, - "issuer": { + "avatar": { "type": "string" }, - "jwks_uri": { + "createtime": { + "type": "integer" + }, + "email": { "type": "string" }, - "scopes_supported": { - "description": "逗号分隔", + "id": { + "type": "integer" + }, + "ip_location": { "type": "string" }, - "token_endpoint": { + "register_ip": { "type": "string" }, - "userinfo_endpoint": { + "status": { + "type": "integer" + }, + "username": { "type": "string" } } }, - "admin.FeedbackItem": { + "announcement.AdminAnnouncement": { "type": "object", "properties": { - "client_ip": { - "type": "string" - }, "content": { + "description": "JSON string", "type": "string" }, "createtime": { @@ -8784,53 +9930,57 @@ "id": { "type": "integer" }, - "reason": { + "level": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "title": { + "description": "JSON string", "type": "string" + }, + "updatetime": { + "type": "integer" } } }, - "admin.GetMigrateAvatarStatusResponse": { + "announcement.AdminCreateRequest": { "type": "object", "properties": { - "failed": { - "type": "integer" - }, - "message": { + "content": { "type": "string" }, - "migrated": { - "type": "integer" - }, - "running": { - "type": "boolean" - }, - "skipped": { + "level": { "type": "integer" }, - "total": { - "type": "integer" + "title": { + "type": "string" } } }, - "admin.GetSystemConfigsResponse": { + "announcement.AdminCreateResponse": { "type": "object", "properties": { - "configs": { - "type": "array", - "items": { - "$ref": "#/definitions/admin.SystemConfigItem" - } + "id": { + "type": "integer" } } }, - "admin.ListFeedbackResponse": { + "announcement.AdminDeleteRequest": { + "type": "object" + }, + "announcement.AdminDeleteResponse": { + "type": "object" + }, + "announcement.AdminListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/admin.FeedbackItem" + "$ref": "#/definitions/announcement.AdminAnnouncement" } }, "total": { @@ -8838,29 +9988,57 @@ } } }, - "admin.ListOAuthAppsResponse": { + "announcement.AdminUpdateRequest": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/admin.OAuthAppItem" - } + "content": { + "type": "string" }, - "total": { + "level": { + "type": "integer" + }, + "status": { "type": "integer" + }, + "title": { + "type": "string" } } }, - "admin.ListOIDCProvidersResponse": { + "announcement.AdminUpdateResponse": { + "type": "object" + }, + "announcement.Announcement": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "level": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, + "announcement.LatestResponse": { + "type": "object" + }, + "announcement.ListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/admin.OIDCProviderItem" + "$ref": "#/definitions/announcement.Announcement" } }, "total": { @@ -8868,14 +10046,49 @@ } } }, - "admin.ListReportsResponse": { + "audit.AuditLogItem": { + "type": "object", + "properties": { + "action": { + "$ref": "#/definitions/audit_entity.Action" + }, + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "target_id": { + "type": "integer" + }, + "target_name": { + "type": "string" + }, + "target_type": { + "type": "string" + }, + "user_id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "audit.ListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/admin.AdminReportItem" + "$ref": "#/definitions/audit.AuditLogItem" } }, "total": { @@ -8883,14 +10096,14 @@ } } }, - "admin.ListScoresResponse": { + "audit.ScriptListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/admin.ScoreItem" + "$ref": "#/definitions/audit.AuditLogItem" } }, "total": { @@ -8898,194 +10111,200 @@ } } }, - "admin.ListScriptsResponse": { + "audit_entity.Action": { + "description": "Action enum type:\n- ActionScriptCreate: script_create\n- ActionScriptUpdate: script_update\n- ActionScriptDelete: script_delete", + "type": "string", + "enum": [ + "script_create", + "script_update", + "script_delete" + ] + }, + "auth.ForgotPasswordRequest": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/admin.ScriptItem" - } + "email": { + "type": "string" }, - "total": { - "type": "integer" + "turnstile_token": { + "type": "string" } } }, - "admin.ListUsersResponse": { + "auth.ForgotPasswordResponse": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/admin.UserItem" - } - }, - "total": { - "type": "integer" + "message": { + "type": "string" } } }, - "admin.MigrateAvatarRequest": { - "type": "object" - }, - "admin.MigrateAvatarResponse": { - "type": "object" - }, - "admin.OAuthAppItem": { + "auth.JWK": { "type": "object", "properties": { - "client_id": { + "alg": { "type": "string" }, - "createtime": { - "type": "integer" - }, - "description": { + "e": { "type": "string" }, - "id": { - "type": "integer" - }, - "name": { + "kid": { "type": "string" }, - "redirect_uri": { + "kty": { "type": "string" }, - "status": { - "type": "integer" + "n": { + "type": "string" }, - "updatetime": { - "type": "integer" + "use": { + "type": "string" } } }, - "admin.OIDCProviderItem": { + "auth.LoginRequest": { "type": "object", "properties": { - "auth_url": { + "account": { "type": "string" }, - "client_id": { + "password": { + "description": "#nosec G117 -- 这是字段定义", "type": "string" }, - "client_secret": { - "description": "#nosec G117 -- API 响应字段,掩码处理后返回", + "turnstile_token": { "type": "string" + } + } + }, + "auth.LoginResponse": { + "type": "object", + "properties": { + "require_webauthn,omitempty": { + "type": "boolean" }, - "createtime": { - "type": "integer" - }, - "display_order": { - "type": "integer" + "session_token,omitempty": { + "type": "string" + } + } + }, + "auth.OAuth2ApproveRequest": { + "type": "object", + "properties": { + "client_id": { + "type": "string" }, - "icon": { + "nonce": { "type": "string" }, - "id": { - "type": "integer" + "redirect_uri": { + "type": "string" }, - "issuer_url": { + "scope": { "type": "string" }, - "name": { + "state": { + "type": "string" + } + } + }, + "auth.OAuth2ApproveResponse": { + "type": "object", + "properties": { + "redirect_uri": { + "type": "string" + } + } + }, + "auth.OAuth2AuthorizeResponse": { + "type": "object", + "properties": { + "app_name": { "type": "string" }, - "scopes": { + "client_id": { "type": "string" }, - "status": { - "type": "integer" + "description": { + "type": "string" }, - "token_url": { + "nonce": { "type": "string" }, - "type": { + "redirect_uri": { "type": "string" }, - "updatetime": { - "type": "integer" + "scope": { + "type": "string" }, - "userinfo_url": { + "state": { "type": "string" } } }, - "admin.ResetOAuthAppSecretRequest": { - "type": "object" - }, - "admin.ResetOAuthAppSecretResponse": { + "auth.OAuth2TokenRequest": { "type": "object", "properties": { "client_id": { "type": "string" }, "client_secret": { - "description": "#nosec G117 -- API 响应字段,非硬编码密码", + "description": "#nosec G117 -- API 请求字段,非硬编码密码", "type": "string" }, - "id": { - "type": "integer" + "code": { + "type": "string" + }, + "grant_type": { + "type": "string" + }, + "redirect_uri": { + "type": "string" } } }, - "admin.ScoreItem": { + "auth.OAuth2TokenResponse": { "type": "object", "properties": { - "createtime": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "message": { + "access_token": { + "description": "#nosec G117 -- API 响应字段,非硬编码密码", "type": "string" }, - "score": { - "type": "integer" - }, - "script_id": { + "expires_in": { "type": "integer" }, - "script_name": { + "id_token,omitempty": { "type": "string" }, - "user_id": { - "type": "integer" + "scope,omitempty": { + "type": "string" }, - "username": { + "token_type": { "type": "string" } } }, - "admin.ScriptItem": { + "auth.OAuth2UserInfoResponse": { "type": "object", "properties": { - "createtime": { - "type": "integer" + "avatar": { + "type": "string" }, - "id": { - "type": "integer" + "email": { + "type": "string" }, "name": { "type": "string" }, - "public": { - "type": "integer" - }, - "status": { - "type": "integer" - }, - "type": { - "type": "integer" + "picture": { + "type": "string" }, - "unwell": { - "type": "integer" + "sub": { + "description": "OIDC standard claims", + "type": "string" }, - "updatetime": { + "uid": { + "description": "Legacy fields (backward compatible)", "type": "integer" }, "user_id": { @@ -9096,980 +10315,1130 @@ } } }, - "admin.SystemConfigItem": { + "auth.OIDCBindConfirmRequest": { "type": "object", "properties": { - "key": { + "account": { "type": "string" }, - "value": { + "bind_token": { + "type": "string" + }, + "password": { + "description": "#nosec G117 -- 这是字段定义", "type": "string" } } }, - "admin.UpdateOAuthAppRequest": { + "auth.OIDCBindConfirmResponse": { + "type": "object" + }, + "auth.OIDCBindInfoResponse": { "type": "object", "properties": { - "description": { + "email": { "type": "string" }, "name": { "type": "string" }, - "redirect_uri": { + "picture": { "type": "string" }, - "status": { + "provider_icon": { + "type": "string" + }, + "provider_id": { "type": "integer" + }, + "provider_name": { + "type": "string" } } }, - "admin.UpdateOAuthAppResponse": { - "type": "object" - }, - "admin.UpdateOIDCProviderRequest": { + "auth.OIDCDiscoveryResponse": { "type": "object", "properties": { - "auth_url": { - "type": "string" - }, - "client_id": { - "type": "string" - }, - "client_secret": { - "description": "#nosec G117 -- API 请求字段,非硬编码密码", + "authorization_endpoint": { "type": "string" }, - "display_order": { - "type": "integer" + "grant_types_supported": { + "type": "array", + "items": { + "type": "string" + } }, - "icon": { - "type": "string" + "id_token_signing_alg_values_supported": { + "type": "array", + "items": { + "type": "string" + } }, - "issuer_url": { + "issuer": { "type": "string" }, - "name": { + "jwks_uri": { "type": "string" }, - "scopes": { - "type": "string" + "response_types_supported": { + "type": "array", + "items": { + "type": "string" + } }, - "status": { - "type": "integer" + "scopes_supported": { + "type": "array", + "items": { + "type": "string" + } }, - "token_url": { - "type": "string" + "subject_types_supported": { + "type": "array", + "items": { + "type": "string" + } }, - "type": { + "token_endpoint": { "type": "string" }, - "userinfo_url": { + "userinfo_endpoint": { "type": "string" } } }, - "admin.UpdateOIDCProviderResponse": { - "type": "object" - }, - "admin.UpdateSystemConfigsRequest": { + "auth.OIDCJWKSResponse": { "type": "object", "properties": { - "configs": { + "keys": { "type": "array", "items": { - "$ref": "#/definitions/admin.SystemConfigItem" + "$ref": "#/definitions/auth.JWK" } } } }, - "admin.UpdateSystemConfigsResponse": { - "type": "object" - }, - "admin.UpdateUserAdminLevelRequest": { + "auth.OIDCProviderInfo": { "type": "object", "properties": { - "admin_level": { + "icon": { + "type": "string" + }, + "id": { "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" } } }, - "admin.UpdateUserAdminLevelResponse": { - "type": "object" - }, - "admin.UpdateUserStatusRequest": { + "auth.OIDCProvidersResponse": { "type": "object", "properties": { - "status": { - "type": "integer" + "providers": { + "type": "array", + "items": { + "$ref": "#/definitions/auth.OIDCProviderInfo" + } } } }, - "admin.UpdateUserStatusResponse": { - "type": "object" - }, - "admin.UserItem": { + "auth.OIDCRegisterAndBindRequest": { "type": "object", "properties": { - "admin_level": { - "type": "integer" - }, - "avatar": { + "-": { + "description": "由 Controller 层设置,不从请求中读取", "type": "string" }, - "createtime": { - "type": "integer" + "agree_terms": { + "type": "boolean" }, - "email": { + "bind_token": { "type": "string" }, - "id": { - "type": "integer" - }, - "ip_location": { + "code": { "type": "string" }, - "register_ip": { + "email": { "type": "string" }, - "status": { - "type": "integer" + "password": { + "description": "#nosec G117 -- 这是字段定义", + "type": "string" }, "username": { "type": "string" } } }, - "announcement.AdminAnnouncement": { + "auth.OIDCRegisterAndBindResponse": { + "type": "object" + }, + "auth.RegisterRequest": { "type": "object", "properties": { - "content": { - "description": "JSON string", + "-": { + "description": "由 Controller 层设置,不从请求中读取", "type": "string" }, - "createtime": { - "type": "integer" - }, - "id": { - "type": "integer" + "agree_terms": { + "type": "boolean" }, - "level": { - "type": "integer" + "code": { + "type": "string" }, - "status": { - "type": "integer" + "email": { + "type": "string" }, - "title": { - "description": "JSON string", + "password": { + "description": "#nosec G117 -- 这是字段定义", "type": "string" }, - "updatetime": { - "type": "integer" + "username": { + "type": "string" } } }, - "announcement.AdminCreateRequest": { + "auth.RegisterResponse": { "type": "object", "properties": { - "content": { - "type": "string" - }, - "level": { - "type": "integer" - }, - "title": { + "message": { "type": "string" } } }, - "announcement.AdminCreateResponse": { + "auth.ResetPasswordRequest": { "type": "object", "properties": { - "id": { - "type": "integer" + "password": { + "description": "#nosec G117 -- 这是字段定义", + "type": "string" + }, + "token": { + "type": "string" } } }, - "announcement.AdminDeleteRequest": { - "type": "object" - }, - "announcement.AdminDeleteResponse": { - "type": "object" - }, - "announcement.AdminListResponse": { + "auth.ResetPasswordResponse": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/announcement.AdminAnnouncement" - } - }, - "total": { - "type": "integer" + "message": { + "type": "string" } } }, - "announcement.AdminUpdateRequest": { + "auth.SendRegisterCodeRequest": { "type": "object", "properties": { - "content": { + "email": { "type": "string" }, - "level": { - "type": "integer" - }, - "status": { - "type": "integer" - }, - "title": { + "turnstile_token": { "type": "string" } } }, - "announcement.AdminUpdateResponse": { - "type": "object" - }, - "announcement.Announcement": { + "auth.SendRegisterCodeResponse": { "type": "object", "properties": { - "content": { + "message": { "type": "string" - }, + } + } + }, + "auth.UserOAuthBindItem": { + "type": "object", + "properties": { "createtime": { "type": "integer" }, "id": { "type": "integer" }, - "level": { - "type": "integer" + "provider": { + "type": "string" }, - "title": { + "provider_name": { + "type": "string" + }, + "provider_username": { "type": "string" } } }, - "announcement.LatestResponse": { - "type": "object" - }, - "announcement.ListResponse": { + "auth.UserOAuthListResponse": { "type": "object", "properties": { - "list": { + "items": { "type": "array", "items": { - "type": "object", - "$ref": "#/definitions/announcement.Announcement" + "$ref": "#/definitions/auth.UserOAuthBindItem" } - }, - "total": { - "type": "integer" } } }, - "audit.AuditLogItem": { + "auth.UserOAuthUnbindRequest": { + "type": "object" + }, + "auth.UserOAuthUnbindResponse": { + "type": "object" + }, + "auth.WebAuthnCredentialItem": { "type": "object", "properties": { - "action": { - "$ref": "#/definitions/audit_entity.Action" - }, "createtime": { "type": "integer" }, "id": { "type": "integer" }, - "is_admin": { - "type": "boolean" - }, - "reason": { - "type": "string" - }, - "target_id": { - "type": "integer" - }, - "target_name": { - "type": "string" - }, - "target_type": { - "type": "string" - }, - "user_id": { - "type": "integer" - }, - "username": { + "name": { "type": "string" } } }, - "audit.ListResponse": { - "type": "object", - "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/audit.AuditLogItem" - } - }, - "total": { - "type": "integer" - } - } + "auth.WebAuthnDeleteCredentialRequest": { + "type": "object" }, - "audit.ScriptListResponse": { + "auth.WebAuthnDeleteCredentialResponse": { + "type": "object" + }, + "auth.WebAuthnListCredentialsResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { - "type": "object", - "$ref": "#/definitions/audit.AuditLogItem" + "$ref": "#/definitions/auth.WebAuthnCredentialItem" } - }, - "total": { - "type": "integer" } } }, - "audit_entity.Action": { - "description": "Action enum type:\n- ActionScriptCreate: script_create\n- ActionScriptUpdate: script_update\n- ActionScriptDelete: script_delete", - "type": "string", - "enum": [ - "script_create", - "script_update", - "script_delete" - ] - }, - "auth.ForgotPasswordRequest": { + "auth.WebAuthnLoginBeginRequest": { "type": "object", "properties": { - "email": { - "type": "string" - }, - "turnstile_token": { + "session_token": { + "description": "#nosec G117 -- 这是字段定义,非硬编码凭据", "type": "string" } } }, - "auth.ForgotPasswordResponse": { + "auth.WebAuthnLoginBeginResponse": { "type": "object", "properties": { - "message": { - "type": "string" + "options": { + "type": "object" } } }, - "auth.JWK": { + "auth.WebAuthnLoginFinishRequest": { "type": "object", "properties": { - "alg": { - "type": "string" - }, - "e": { - "type": "string" - }, - "kid": { - "type": "string" - }, - "kty": { - "type": "string" - }, - "n": { + "credential": { + "description": "JSON-encoded assertion response", "type": "string" }, - "use": { + "session_token": { + "description": "#nosec G117 -- 这是字段定义,非硬编码凭据", "type": "string" } } }, - "auth.LoginRequest": { + "auth.WebAuthnLoginFinishResponse": { + "type": "object" + }, + "auth.WebAuthnPasswordlessBeginRequest": { + "type": "object" + }, + "auth.WebAuthnPasswordlessBeginResponse": { "type": "object", "properties": { - "account": { + "challenge_id": { "type": "string" }, - "password": { - "description": "#nosec G117 -- 这是字段定义", + "options": { + "type": "object" + } + } + }, + "auth.WebAuthnPasswordlessFinishRequest": { + "type": "object", + "properties": { + "challenge_id": { "type": "string" }, - "turnstile_token": { + "credential": { + "description": "JSON-encoded assertion response", "type": "string" } } }, - "auth.LoginResponse": { + "auth.WebAuthnPasswordlessFinishResponse": { + "type": "object" + }, + "auth.WebAuthnRegisterBeginRequest": { + "type": "object" + }, + "auth.WebAuthnRegisterBeginResponse": { "type": "object", "properties": { - "require_webauthn,omitempty": { - "type": "boolean" + "options": { + "type": "object" }, - "session_token,omitempty": { + "session_id": { "type": "string" } } }, - "auth.OAuth2ApproveRequest": { + "auth.WebAuthnRegisterFinishRequest": { "type": "object", "properties": { - "client_id": { - "type": "string" - }, - "nonce": { + "credential": { + "description": "JSON-encoded attestation response", "type": "string" }, - "redirect_uri": { + "name": { "type": "string" }, - "scope": { + "session_id": { "type": "string" - }, - "state": { + } + } + }, + "auth.WebAuthnRegisterFinishResponse": { + "type": "object", + "properties": { + "message": { "type": "string" } } }, - "auth.OAuth2ApproveResponse": { + "auth.WebAuthnRenameCredentialRequest": { "type": "object", "properties": { - "redirect_uri": { + "name": { "type": "string" } } }, - "auth.OAuth2AuthorizeResponse": { + "auth.WebAuthnRenameCredentialResponse": { + "type": "object" + }, + "chat.CreateSessionRequest": { "type": "object", "properties": { - "app_name": { + "title": { "type": "string" + } + } + }, + "chat.CreateSessionResponse": { + "type": "object" + }, + "chat.DeleteSessionRequest": { + "type": "object" + }, + "chat.DeleteSessionResponse": { + "type": "object" + }, + "chat.ListMessagesResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/chat.Message" + } }, - "client_id": { - "type": "string" + "total": { + "type": "integer" + } + } + }, + "chat.ListSessionsResponse": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/chat.Session" + } }, - "description": { + "total": { + "type": "integer" + } + } + }, + "chat.Message": { + "type": "object", + "properties": { + "content": { "type": "string" }, - "nonce": { - "type": "string" + "createtime": { + "type": "integer" }, - "redirect_uri": { - "type": "string" + "id": { + "type": "integer" }, - "scope": { + "role": { "type": "string" }, - "state": { - "type": "string" + "session_id": { + "type": "integer" } } }, - "auth.OAuth2TokenRequest": { + "chat.Session": { "type": "object", "properties": { - "client_id": { - "type": "string" - }, - "client_secret": { - "description": "#nosec G117 -- API 请求字段,非硬编码密码", - "type": "string" + "createtime": { + "type": "integer" }, - "code": { - "type": "string" + "id": { + "type": "integer" }, - "grant_type": { + "title": { "type": "string" }, - "redirect_uri": { - "type": "string" + "updatetime": { + "type": "integer" } } }, - "auth.OAuth2TokenResponse": { + "httputils.PageRequest": { "type": "object", "properties": { - "access_token": { - "description": "#nosec G117 -- API 响应字段,非硬编码密码", + "order": { + "description": "Deprecated 请使用方法GetOrder", "type": "string" }, - "expires_in": { + "page": { + "description": "Deprecated 请使用方法GetPage", "type": "integer" }, - "id_token,omitempty": { - "type": "string" - }, - "scope,omitempty": { - "type": "string" + "size": { + "description": "Deprecated 请使用方法GetSize", + "type": "integer" }, - "token_type": { + "sort": { + "description": "Deprecated 请使用方法GetSort", "type": "string" } } }, - "auth.OAuth2UserInfoResponse": { + "httputils.PageResponse": { "type": "object", "properties": { - "avatar": { - "type": "string" - }, - "email": { - "type": "string" + "list": { + "type": "array", + "items": { + "type": "any" + } }, - "name": { + "total": { + "type": "integer" + } + } + }, + "issue.Comment": { + "type": "object", + "properties": { + "content": { "type": "string" }, - "picture": { - "type": "string" + "createtime": { + "type": "integer" }, - "sub": { - "description": "OIDC standard claims", - "type": "string" + "id": { + "type": "integer" }, - "uid": { - "description": "Legacy fields (backward compatible)", + "issue_id": { "type": "integer" }, - "user_id": { + "status": { "type": "integer" }, - "username": { - "type": "string" + "type": { + "$ref": "#/definitions/issue_entity.CommentType" + }, + "updatetime": { + "type": "integer" } } }, - "auth.OIDCBindConfirmRequest": { + "issue.CreateCommentRequest": { "type": "object", "properties": { - "account": { - "type": "string" - }, - "bind_token": { - "type": "string" - }, - "password": { - "description": "#nosec G117 -- 这是字段定义", + "content": { "type": "string" } } }, - "auth.OIDCBindConfirmResponse": { + "issue.CreateCommentResponse": { "type": "object" }, - "auth.OIDCBindInfoResponse": { + "issue.CreateIssueRequest": { "type": "object", "properties": { - "email": { - "type": "string" - }, - "name": { + "content": { "type": "string" }, - "picture": { - "type": "string" + "labels": { + "type": "array", + "items": { + "type": "string" + } }, - "provider_icon": { + "title": { "type": "string" - }, - "provider_id": { + } + } + }, + "issue.CreateIssueResponse": { + "type": "object", + "properties": { + "id": { "type": "integer" - }, - "provider_name": { - "type": "string" } } }, - "auth.OIDCDiscoveryResponse": { + "issue.DeleteCommentRequest": { + "type": "object" + }, + "issue.DeleteCommentResponse": { + "type": "object" + }, + "issue.DeleteRequest": { + "type": "object" + }, + "issue.DeleteResponse": { + "type": "object" + }, + "issue.GetIssueResponse": { "type": "object", "properties": { - "authorization_endpoint": { + "content": { "type": "string" + } + } + }, + "issue.GetWatchResponse": { + "type": "object", + "properties": { + "watch": { + "type": "boolean" + } + } + }, + "issue.Issue": { + "type": "object", + "properties": { + "comment_count": { + "type": "integer" }, - "grant_types_supported": { - "type": "array", - "items": { - "type": "string" - } - }, - "id_token_signing_alg_values_supported": { - "type": "array", - "items": { - "type": "string" - } - }, - "issuer": { - "type": "string" + "createtime": { + "type": "integer" }, - "jwks_uri": { - "type": "string" + "id": { + "type": "integer" }, - "response_types_supported": { + "labels": { "type": "array", "items": { "type": "string" } }, - "scopes_supported": { - "type": "array", - "items": { - "type": "string" - } + "script_id": { + "type": "integer" }, - "subject_types_supported": { - "type": "array", - "items": { - "type": "string" - } + "status": { + "type": "integer" }, - "token_endpoint": { + "title": { "type": "string" }, - "userinfo_endpoint": { - "type": "string" + "updatetime": { + "type": "integer" } } }, - "auth.OIDCJWKSResponse": { + "issue.ListCommentResponse": { "type": "object", "properties": { - "keys": { + "list": { "type": "array", "items": { - "$ref": "#/definitions/auth.JWK" + "type": "object", + "$ref": "#/definitions/issue.Comment" } + }, + "total": { + "type": "integer" } } }, - "auth.OIDCProviderInfo": { + "issue.ListResponse": { "type": "object", "properties": { - "icon": { - "type": "string" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/issue.Issue" + } }, - "id": { + "total": { "type": "integer" + } + } + }, + "issue.OpenRequest": { + "type": "object", + "properties": { + "close": { + "description": "true:关闭 false:打开", + "type": "boolean" }, - "name": { - "type": "string" - }, - "type": { + "content": { "type": "string" } } }, - "auth.OIDCProvidersResponse": { + "issue.OpenResponse": { "type": "object", "properties": { - "providers": { + "comments": { "type": "array", "items": { - "$ref": "#/definitions/auth.OIDCProviderInfo" + "$ref": "#/definitions/issue.Comment" } } } }, - "auth.OIDCRegisterAndBindRequest": { + "issue.UpdateLabelsRequest": { "type": "object", "properties": { - "agree_terms": { - "type": "boolean" - }, - "bind_token": { - "type": "string" - }, - "code": { - "type": "string" - }, - "email": { - "type": "string" - }, - "password": { - "description": "#nosec G117 -- 这是字段定义", - "type": "string" - }, - "username": { - "type": "string" + "labels": { + "type": "array", + "items": { + "type": "string" + } } } }, - "auth.OIDCRegisterAndBindResponse": { + "issue.UpdateLabelsResponse": { "type": "object" }, - "auth.RegisterRequest": { + "issue.WatchRequest": { "type": "object", "properties": { - "-": { - "description": "由 Controller 层设置,不从请求中读取", - "type": "string" - }, - "agree_terms": { + "watch": { "type": "boolean" - }, - "code": { - "type": "string" - }, - "email": { - "type": "string" - }, - "password": { - "description": "#nosec G117 -- 这是字段定义", - "type": "string" - }, - "username": { - "type": "string" } } }, - "auth.RegisterResponse": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - } + "issue.WatchResponse": { + "type": "object" }, - "auth.ResetPasswordRequest": { + "issue_entity.CommentType": { + "description": "CommentType enum type:\n- CommentTypeComment: 1\n- CommentTypeChangeTitle: 2\n- CommentTypeChangeLabel: 3\n- CommentTypeOpen: 4\n- CommentTypeClose: 5\n- CommentTypeDelete: 6", + "type": "integer", + "enum": [ + 1, + 2, + 3, + 4, + 5, + 6 + ] + }, + "model.AdminLevel": { + "description": "AdminLevel enum type:\n- Admin: 1\n- SuperModerator: 2\n- Moderator: 3", + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + }, + "notification.BatchMarkReadRequest": { "type": "object", "properties": { - "password": { - "description": "#nosec G117 -- 这是字段定义", - "type": "string" - }, - "token": { - "type": "string" + "ids": { + "description": "通知ID列表,为空则全部标记已读", + "type": "array", + "items": { + "type": "integer" + } } } }, - "auth.ResetPasswordResponse": { + "notification.BatchMarkReadResponse": { + "type": "object" + }, + "notification.GetUnreadCountResponse": { "type": "object", "properties": { - "message": { - "type": "string" + "total": { + "description": "总未读数", + "type": "integer" } } }, - "auth.SendRegisterCodeRequest": { + "notification.ListResponse": { "type": "object", "properties": { - "email": { - "type": "string" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/notification.Notification" + } }, - "turnstile_token": { - "type": "string" + "total": { + "type": "integer" } } }, - "auth.SendRegisterCodeResponse": { + "notification.MarkReadRequest": { "type": "object", "properties": { - "message": { - "type": "string" + "unread": { + "type": "integer" } } }, - "auth.UserOAuthBindItem": { + "notification.MarkReadResponse": { + "type": "object" + }, + "notification.Notification": { "type": "object", "properties": { + "content": { + "description": "通知内容", + "type": "string" + }, "createtime": { "type": "integer" }, + "from_user,omitempty": { + "description": "发起用户信息", + "$ref": "#/definitions/user_entity.UserInfo" + }, "id": { "type": "integer" }, - "provider": { + "link": { + "description": "通知链接", "type": "string" }, - "provider_name": { - "type": "string" + "params,omitempty": { + "description": "额外参数", + "type": "object" }, - "provider_username": { + "read_status": { + "description": "0:未读 1:已读", + "type": "integer" + }, + "read_time,omitempty": { + "description": "阅读时间", + "type": "integer" + }, + "title": { + "description": "通知标题", "type": "string" + }, + "type": { + "description": "通知类型", + "$ref": "#/definitions/notification_entity.Type" + }, + "updatetime": { + "type": "integer" + }, + "user_id": { + "type": "integer" } } }, - "auth.UserOAuthListResponse": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/auth.UserOAuthBindItem" - } - } - } - }, - "auth.UserOAuthUnbindRequest": { - "type": "object" + "notification_entity.Type": { + "description": "Type enum type:\n- ScriptUpdateTemplate: 100\n- IssueCreateTemplate: 101\n- CommentCreateTemplate: 102\n- ScriptScoreTemplate: 103\n- AccessInviteTemplate: 104\n- ScriptScoreReplyTemplate: 105\n- ReportCreateTemplate: 106\n- ReportCommentTemplate: 107\n- ScriptDeleteTemplate: 108", + "type": "integer", + "enum": [ + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108 + ] }, - "auth.UserOAuthUnbindResponse": { + "open.CrxDownloadResponse": { "type": "object" }, - "auth.WebAuthnCredentialItem": { + "report.Comment": { "type": "object", "properties": { + "content": { + "type": "string" + }, "createtime": { "type": "integer" }, "id": { "type": "integer" }, - "name": { + "report_id": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "type": { + "$ref": "#/definitions/report_entity.CommentType" + }, + "updatetime": { + "type": "integer" + } + } + }, + "report.CreateCommentRequest": { + "type": "object", + "properties": { + "content": { "type": "string" } } }, - "auth.WebAuthnDeleteCredentialRequest": { + "report.CreateCommentResponse": { "type": "object" }, - "auth.WebAuthnDeleteCredentialResponse": { - "type": "object" + "report.CreateReportRequest": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "reason": { + "type": "string" + } + } }, - "auth.WebAuthnListCredentialsResponse": { + "report.CreateReportResponse": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "$ref": "#/definitions/auth.WebAuthnCredentialItem" - } + "id": { + "type": "integer" } } }, - "auth.WebAuthnLoginBeginRequest": { + "report.DeleteCommentRequest": { + "type": "object" + }, + "report.DeleteCommentResponse": { + "type": "object" + }, + "report.DeleteRequest": { + "type": "object" + }, + "report.DeleteResponse": { + "type": "object" + }, + "report.GetReportResponse": { "type": "object", "properties": { - "session_token": { - "description": "#nosec G117 -- 这是字段定义,非硬编码凭据", + "content": { "type": "string" } } }, - "auth.WebAuthnLoginBeginResponse": { + "report.ListCommentResponse": { "type": "object", "properties": { - "options": { - "type": "object" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/report.Comment" + } + }, + "total": { + "type": "integer" } } }, - "auth.WebAuthnLoginFinishRequest": { + "report.ListResponse": { "type": "object", "properties": { - "credential": { - "description": "JSON-encoded assertion response", - "type": "string" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/report.Report" + } }, - "session_token": { - "description": "#nosec G117 -- 这是字段定义,非硬编码凭据", - "type": "string" + "total": { + "type": "integer" } } }, - "auth.WebAuthnLoginFinishResponse": { - "type": "object" - }, - "auth.WebAuthnPasswordlessBeginRequest": { - "type": "object" - }, - "auth.WebAuthnPasswordlessBeginResponse": { + "report.Report": { "type": "object", "properties": { - "challenge_id": { + "comment_count": { + "type": "integer" + }, + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "reason": { "type": "string" }, - "options": { - "type": "object" + "script_id": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "updatetime": { + "type": "integer" } } }, - "auth.WebAuthnPasswordlessFinishRequest": { + "report.ResolveRequest": { "type": "object", "properties": { - "challenge_id": { - "type": "string" + "close": { + "description": "true:解决 false:重新打开", + "type": "boolean" }, - "credential": { - "description": "JSON-encoded assertion response", + "content": { "type": "string" } } }, - "auth.WebAuthnPasswordlessFinishResponse": { - "type": "object" + "report.ResolveResponse": { + "type": "object", + "properties": { + "comments": { + "type": "array", + "items": { + "$ref": "#/definitions/report.Comment" + } + } + } }, - "auth.WebAuthnRegisterBeginRequest": { - "type": "object" + "report_entity.CommentType": { + "description": "CommentType enum type:\n- CommentTypeComment: 1\n- CommentTypeResolve: 2\n- CommentTypeReopen: 3", + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] }, - "auth.WebAuthnRegisterBeginResponse": { + "resource.UploadImageRequest": { "type": "object", "properties": { - "options": { - "type": "object" - }, - "session_id": { + "comment": { "type": "string" + }, + "link_id": { + "type": "integer" } } }, - "auth.WebAuthnRegisterFinishRequest": { + "resource.UploadImageResponse": { "type": "object", "properties": { - "credential": { - "description": "JSON-encoded attestation response", + "comment": { "type": "string" }, - "name": { + "content_type": { "type": "string" }, - "session_id": { + "createtime": { + "type": "integer" + }, + "id": { "type": "string" - } - } - }, - "auth.WebAuthnRegisterFinishResponse": { - "type": "object", - "properties": { - "message": { + }, + "link_id": { + "type": "integer" + }, + "name": { "type": "string" } } }, - "auth.WebAuthnRenameCredentialRequest": { + "script.AcceptInviteRequest": { "type": "object", "properties": { - "name": { - "type": "string" + "accept": { + "description": "邀请码类型不能拒绝", + "type": "boolean" } } }, - "auth.WebAuthnRenameCredentialResponse": { + "script.AcceptInviteResponse": { "type": "object" }, - "chat.CreateSessionRequest": { + "script.Access": { "type": "object", "properties": { - "title": { + "avatar": { + "type": "string" + }, + "createtime": { + "type": "integer" + }, + "expiretime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "invite_status": { + "description": "邀请状态 1=已接受 2=已拒绝 3=待接受", + "$ref": "#/definitions/script_entity.AccessInviteStatus" + }, + "is_expire": { + "type": "boolean" + }, + "link_id": { + "description": "关联id", + "type": "integer" + }, + "name": { "type": "string" + }, + "role": { + "$ref": "#/definitions/script_entity.AccessRole" + }, + "type": { + "description": "id类型 1=用户id 2=组id", + "$ref": "#/definitions/script_entity.AccessType" } } }, - "chat.CreateSessionResponse": { - "type": "object" - }, - "chat.DeleteSessionRequest": { - "type": "object" - }, - "chat.DeleteSessionResponse": { - "type": "object" - }, - "chat.ListMessagesResponse": { + "script.AccessListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/chat.Message" + "$ref": "#/definitions/script.Access" } }, "total": { @@ -10077,168 +11446,160 @@ } } }, - "chat.ListSessionsResponse": { + "script.AddGroupAccessRequest": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/chat.Session" - } + "expiretime,default=0": { + "description": "0 为永久", + "type": "integer" }, - "total": { + "group_id": { "type": "integer" + }, + "role": { + "description": "访问权限 guest=访客 manager=管理员", + "$ref": "#/definitions/script_entity.AccessRole" } } }, - "chat.Message": { + "script.AddGroupAccessResponse": { + "type": "object" + }, + "script.AddMemberRequest": { "type": "object", "properties": { - "content": { - "type": "string" - }, - "createtime": { - "type": "integer" - }, - "id": { + "expiretime": { "type": "integer" }, - "role": { - "type": "string" - }, - "session_id": { + "user_id": { "type": "integer" } } }, - "chat.Session": { + "script.AddMemberResponse": { + "type": "object" + }, + "script.AddUserAccessRequest": { "type": "object", "properties": { - "createtime": { - "type": "integer" - }, - "id": { + "expiretime,default=0": { + "description": "0 为永久", "type": "integer" }, - "title": { - "type": "string" + "role": { + "description": "访问权限 guest=访客 manager=管理员", + "$ref": "#/definitions/script_entity.AccessRole" }, - "updatetime": { + "user_id": { "type": "integer" } } }, - "httputils.PageRequest": { + "script.AddUserAccessResponse": { + "type": "object" + }, + "script.ArchiveRequest": { "type": "object", "properties": { - "order": { - "description": "Deprecated 请使用方法GetOrder", - "type": "string" - }, - "page": { - "description": "Deprecated 请使用方法GetPage", - "type": "integer" - }, - "size": { - "description": "Deprecated 请使用方法GetSize", - "type": "integer" - }, - "sort": { - "description": "Deprecated 请使用方法GetSort", - "type": "string" + "archive": { + "type": "boolean" } } }, - "httputils.PageResponse": { + "script.ArchiveResponse": { + "type": "object" + }, + "script.AuditInviteCodeRequest": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "any" - } - }, - "total": { + "status": { + "description": "1=通过 2=拒绝", "type": "integer" } } }, - "issue.Comment": { + "script.AuditInviteCodeResponse": { + "type": "object" + }, + "script.CategoryListItem": { "type": "object", "properties": { - "content": { - "type": "string" - }, "createtime": { "type": "integer" }, "id": { "type": "integer" }, - "issue_id": { + "name": { + "type": "string" + }, + "num": { + "description": "本分类下脚本数量", "type": "integer" }, - "status": { + "sort": { "type": "integer" }, "type": { - "$ref": "#/definitions/issue_entity.CommentType" + "description": "1:脚本分类 2:脚本标签", + "$ref": "#/definitions/script_entity.ScriptCategoryType" }, "updatetime": { "type": "integer" } } }, - "issue.CreateCommentRequest": { + "script.CategoryListResponse": { "type": "object", "properties": { - "content": { - "type": "string" + "categories": { + "description": "分类列表", + "type": "array", + "items": { + "$ref": "#/definitions/script.CategoryListItem" + } } } }, - "issue.CreateCommentResponse": { - "type": "object" - }, - "issue.CreateIssueRequest": { + "script.Code": { "type": "object", "properties": { - "content": { + "changelog": { "type": "string" }, - "labels": { - "type": "array", - "items": { - "type": "string" - } + "code,omitempty": { + "type": "string" }, - "title": { + "createtime": { + "type": "integer" + }, + "definition,omitempty": { "type": "string" - } - } - }, - "issue.CreateIssueResponse": { - "type": "object", - "properties": { + }, "id": { "type": "integer" - } - } - }, - "issue.DeleteCommentRequest": { - "type": "object" - }, - "issue.DeleteCommentResponse": { - "type": "object" - }, - "issue.DeleteRequest": { - "type": "object" - }, - "issue.DeleteResponse": { - "type": "object" + }, + "is_pre_release": { + "$ref": "#/definitions/script_entity.EnablePreRelease" + }, + "meta,omitempty": { + "type": "string" + }, + "meta_json": { + "type": "object" + }, + "script_id": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "version": { + "type": "string" + } + } }, - "issue.GetIssueResponse": { + "script.CodeResponse": { "type": "object", "properties": { "content": { @@ -10246,177 +11607,271 @@ } } }, - "issue.GetWatchResponse": { + "script.CreateFolderRequest": { "type": "object", "properties": { - "watch": { - "type": "boolean" + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "private": { + "description": "1私密 2公开", + "type": "integer" } } }, - "issue.Issue": { + "script.CreateFolderResponse": { "type": "object", "properties": { - "comment_count": { - "type": "integer" - }, - "createtime": { - "type": "integer" - }, "id": { "type": "integer" + } + } + }, + "script.CreateGroupInviteCodeRequest": { + "type": "object", + "properties": { + "audit": { + "type": "boolean" }, - "labels": { - "type": "array", - "items": { - "type": "string" - } - }, - "script_id": { - "type": "integer" - }, - "status": { + "count": { "type": "integer" }, - "title": { - "type": "string" - }, - "updatetime": { + "days": { + "description": "0 为永久", "type": "integer" } } }, - "issue.ListCommentResponse": { + "script.CreateGroupInviteCodeResponse": { "type": "object", "properties": { - "list": { + "code": { "type": "array", "items": { - "type": "object", - "$ref": "#/definitions/issue.Comment" + "type": "string" } - }, - "total": { - "type": "integer" } } }, - "issue.ListResponse": { + "script.CreateGroupRequest": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/issue.Issue" - } + "description": { + "type": "string" }, - "total": { - "type": "integer" + "name": { + "type": "string" } } }, - "issue.OpenRequest": { + "script.CreateGroupResponse": { + "type": "object" + }, + "script.CreateInviteCodeRequest": { "type": "object", "properties": { - "close": { - "description": "true:关闭 false:打开", + "audit": { "type": "boolean" }, - "content": { - "type": "string" + "count": { + "type": "integer" + }, + "days": { + "description": "0 为永久", + "type": "integer" } } }, - "issue.OpenResponse": { + "script.CreateInviteCodeResponse": { "type": "object", "properties": { - "comments": { + "code": { "type": "array", "items": { - "$ref": "#/definitions/issue.Comment" + "type": "string" } } } }, - "issue.UpdateLabelsRequest": { + "script.CreateRequest": { "type": "object", "properties": { - "labels": { + "category": { + "description": "分类ID", + "type": "integer" + }, + "changelog": { + "type": "string" + }, + "code": { + "type": "string" + }, + "content": { + "type": "string" + }, + "definition": { + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "public": { + "description": "公开类型:1 公开 2 半公开 3 私有", + "$ref": "#/definitions/script_entity.Public" + }, + "tags": { + "description": "标签,只有脚本类型为库时才有意义", "type": "array", "items": { "type": "string" } + }, + "type": { + "description": "脚本类型:1 用户脚本 2 订阅脚本(不支持) 3 脚本引用库", + "$ref": "#/definitions/script_entity.Type" + }, + "unwell": { + "description": "不适内容: 1 不适 2 适用", + "$ref": "#/definitions/script_entity.UnwellContent" + }, + "version": { + "type": "string" } } }, - "issue.UpdateLabelsResponse": { + "script.CreateResponse": { + "type": "object", + "properties": { + "id": { + "type": "integer" + } + } + }, + "script.DelScoreRequest": { "type": "object" }, - "issue.WatchRequest": { + "script.DelScoreResponse": { + "type": "object" + }, + "script.DeleteAccessRequest": { + "type": "object" + }, + "script.DeleteAccessResponse": { + "type": "object" + }, + "script.DeleteCodeRequest": { + "type": "object" + }, + "script.DeleteCodeResponse": { + "type": "object" + }, + "script.DeleteFolderRequest": { + "type": "object" + }, + "script.DeleteFolderResponse": { + "type": "object" + }, + "script.DeleteGroupRequest": { + "type": "object" + }, + "script.DeleteGroupResponse": { + "type": "object" + }, + "script.DeleteInviteCodeRequest": { + "type": "object" + }, + "script.DeleteInviteCodeResponse": { + "type": "object" + }, + "script.DeleteRequest": { "type": "object", "properties": { - "watch": { - "type": "boolean" + "reason": { + "description": "删除原因(可选)", + "type": "string" } } }, - "issue.WatchResponse": { + "script.DeleteResponse": { "type": "object" }, - "issue_entity.CommentType": { - "description": "CommentType enum type:\n- CommentTypeComment: 1\n- CommentTypeChangeTitle: 2\n- CommentTypeChangeLabel: 3\n- CommentTypeOpen: 4\n- CommentTypeClose: 5\n- CommentTypeDelete: 6", - "type": "integer", - "enum": [ - 1, - 2, - 3, - 4, - 5, - 6 - ] + "script.EditFolderRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "private": { + "description": "1私密 2公开", + "type": "integer" + } + } }, - "model.AdminLevel": { - "description": "AdminLevel enum type:\n- Admin: 1\n- SuperModerator: 2\n- Moderator: 3", - "type": "integer", - "enum": [ - 1, - 2, - 3 - ] + "script.EditFolderResponse": { + "type": "object" + }, + "script.FavoriteFolderDetailResponse": { + "type": "object" + }, + "script.FavoriteFolderItem": { + "type": "object", + "properties": { + "count": { + "description": "收藏夹中脚本数量", + "type": "integer" + }, + "description": { + "description": "收藏夹描述", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "private": { + "description": "收藏夹类型 1私密 2公开", + "type": "integer" + }, + "updatetime": { + "description": "收藏夹更新时间", + "type": "integer" + } + } }, - "notification.BatchMarkReadRequest": { + "script.FavoriteFolderListResponse": { "type": "object", "properties": { - "ids": { - "description": "通知ID列表,为空则全部标记已读", + "list": { "type": "array", "items": { - "type": "integer" + "type": "object", + "$ref": "#/definitions/script.FavoriteFolderItem" } - } - } - }, - "notification.BatchMarkReadResponse": { - "type": "object" - }, - "notification.GetUnreadCountResponse": { - "type": "object", - "properties": { + }, "total": { - "description": "总未读数", "type": "integer" } } }, - "notification.ListResponse": { + "script.FavoriteScriptListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/notification.Notification" + "$ref": "#/definitions/script.Script" } }, "total": { @@ -10424,168 +11879,77 @@ } } }, - "notification.MarkReadRequest": { + "script.FavoriteScriptRequest": { "type": "object", "properties": { - "unread": { + "script_id": { "type": "integer" } } }, - "notification.MarkReadResponse": { + "script.FavoriteScriptResponse": { "type": "object" }, - "notification.Notification": { + "script.GetSettingResponse": { "type": "object", "properties": { - "content": { - "description": "通知内容", + "content_url": { "type": "string" }, - "createtime": { - "type": "integer" - }, - "from_user,omitempty": { - "description": "发起用户信息", - "$ref": "#/definitions/user_entity.UserInfo" - }, - "id": { - "type": "integer" - }, - "link": { - "description": "通知链接", + "definition_url": { "type": "string" }, - "params,omitempty": { - "description": "额外参数", - "type": "object" + "enable_pre_release": { + "$ref": "#/definitions/script_entity.EnablePreRelease" }, - "read_status": { - "description": "0:未读 1:已读", - "type": "integer" + "gray_controls": { + "type": "array", + "items": { + "$ref": "#/definitions/script_entity.GrayControl" + } }, - "read_time,omitempty": { - "description": "阅读时间", - "type": "integer" + "sync_mode": { + "$ref": "#/definitions/script_entity.SyncMode" }, - "title": { - "description": "通知标题", + "sync_url": { "type": "string" - }, - "type": { - "description": "通知类型", - "$ref": "#/definitions/notification_entity.Type" - }, - "updatetime": { - "type": "integer" - }, - "user_id": { - "type": "integer" } } }, - "notification_entity.Type": { - "description": "Type enum type:\n- ScriptUpdateTemplate: 100\n- IssueCreateTemplate: 101\n- CommentCreateTemplate: 102\n- ScriptScoreTemplate: 103\n- AccessInviteTemplate: 104\n- ScriptScoreReplyTemplate: 105\n- ReportCreateTemplate: 106\n- ReportCommentTemplate: 107\n- ScriptDeleteTemplate: 108", - "type": "integer", - "enum": [ - 100, - 101, - 102, - 103, - 104, - 105, - 106, - 107, - 108 - ] - }, - "open.CrxDownloadResponse": { - "type": "object" - }, - "report.Comment": { + "script.Group": { "type": "object", "properties": { - "content": { - "type": "string" - }, "createtime": { "type": "integer" }, - "id": { - "type": "integer" - }, - "report_id": { - "type": "integer" + "description": { + "type": "string" }, - "status": { + "id": { "type": "integer" }, - "type": { - "$ref": "#/definitions/report_entity.CommentType" + "member": { + "type": "array", + "items": { + "$ref": "#/definitions/script.GroupMember" + } }, - "updatetime": { + "member_count": { "type": "integer" - } - } - }, - "report.CreateCommentRequest": { - "type": "object", - "properties": { - "content": { - "type": "string" - } - } - }, - "report.CreateCommentResponse": { - "type": "object" - }, - "report.CreateReportRequest": { - "type": "object", - "properties": { - "content": { - "type": "string" }, - "reason": { - "type": "string" - } - } - }, - "report.CreateReportResponse": { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - "report.DeleteCommentRequest": { - "type": "object" - }, - "report.DeleteCommentResponse": { - "type": "object" - }, - "report.DeleteRequest": { - "type": "object" - }, - "report.DeleteResponse": { - "type": "object" - }, - "report.GetReportResponse": { - "type": "object", - "properties": { - "content": { + "name": { "type": "string" } } }, - "report.ListCommentResponse": { + "script.GroupInviteCodeListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/report.Comment" + "$ref": "#/definitions/script.InviteCode" } }, "total": { @@ -10593,14 +11957,14 @@ } } }, - "report.ListResponse": { + "script.GroupListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/report.Report" + "$ref": "#/definitions/script.Group" } }, "total": { @@ -10608,156 +11972,159 @@ } } }, - "report.Report": { + "script.GroupMember": { "type": "object", "properties": { - "comment_count": { - "type": "integer" + "avatar": { + "type": "string" }, "createtime": { "type": "integer" }, - "id": { + "expiretime": { "type": "integer" }, - "reason": { - "type": "string" - }, - "script_id": { + "id": { "type": "integer" }, - "status": { - "type": "integer" + "invite_status": { + "$ref": "#/definitions/script_entity.AccessInviteStatus" }, - "updatetime": { - "type": "integer" - } - } - }, - "report.ResolveRequest": { - "type": "object", - "properties": { - "close": { - "description": "true:解决 false:重新打开", + "is_expire": { "type": "boolean" }, - "content": { + "user_id": { + "type": "integer" + }, + "username": { "type": "string" } } }, - "report.ResolveResponse": { + "script.GroupMemberListResponse": { "type": "object", "properties": { - "comments": { + "list": { "type": "array", "items": { - "$ref": "#/definitions/report.Comment" + "type": "object", + "$ref": "#/definitions/script.GroupMember" } + }, + "total": { + "type": "integer" } } }, - "report_entity.CommentType": { - "description": "CommentType enum type:\n- CommentTypeComment: 1\n- CommentTypeResolve: 2\n- CommentTypeReopen: 3", - "type": "integer", - "enum": [ - 1, - 2, - 3 - ] - }, - "resource.UploadImageRequest": { + "script.InfoResponse": { "type": "object", "properties": { - "comment": { + "content": { "type": "string" }, - "link_id": { - "type": "integer" + "role": { + "$ref": "#/definitions/script_entity.AccessRole" + }, + "sri,omitempty": { + "description": "如果是库的话,会返回sha512 sri", + "type": "string" } } }, - "resource.UploadImageResponse": { + "script.InviteCode": { "type": "object", "properties": { - "comment": { - "type": "string" - }, - "content_type": { + "code": { + "description": "邀请码", "type": "string" }, "createtime": { "type": "integer" }, + "expiretime": { + "description": "到期时间", + "type": "integer" + }, "id": { - "type": "string" + "type": "integer" }, - "link_id": { + "invite_status": { + "$ref": "#/definitions/script_entity.InviteStatus" + }, + "is_audit": { + "description": "是否需要审核", + "type": "boolean" + }, + "used": { + "description": "使用用户", "type": "integer" }, - "name": { + "username": { + "description": "使用用户名", "type": "string" } } }, - "script.AcceptInviteRequest": { + "script.InviteCodeInfoAccess": { "type": "object", "properties": { - "accept": { - "description": "邀请码类型不能拒绝", - "type": "boolean" + "role": { + "description": "访问权限 guest=访客 manager=管理员", + "$ref": "#/definitions/script_entity.AccessRole" } } }, - "script.AcceptInviteResponse": { - "type": "object" - }, - "script.Access": { + "script.InviteCodeInfoGroup": { "type": "object", "properties": { - "avatar": { + "description": { "type": "string" }, - "createtime": { - "type": "integer" + "name": { + "type": "string" + } + } + }, + "script.InviteCodeInfoResponse": { + "type": "object", + "properties": { + "access,omitempty": { + "description": "如果type=1, 则返回权限信息", + "$ref": "#/definitions/script.InviteCodeInfoAccess" }, - "expiretime": { - "type": "integer" + "code_type": { + "description": "邀请码类型 1=邀请码 2=邀请链接", + "$ref": "#/definitions/script_entity.InviteCodeType" }, - "id": { - "type": "integer" + "group,omitempty": { + "description": "如果type=2, 则返回群组信息", + "$ref": "#/definitions/script.InviteCodeInfoGroup" }, "invite_status": { - "description": "邀请状态 1=已接受 2=已拒绝 3=待接受", - "$ref": "#/definitions/script_entity.AccessInviteStatus" + "description": "使用状态", + "$ref": "#/definitions/script_entity.InviteStatus" }, - "is_expire": { + "is_audit": { + "description": "是否需要审核 邀请码类型为邀请链接时,该字段固定为false", "type": "boolean" }, - "link_id": { - "description": "关联id", - "type": "integer" - }, - "name": { - "type": "string" - }, - "role": { - "$ref": "#/definitions/script_entity.AccessRole" + "script": { + "$ref": "#/definitions/script.Script" }, "type": { - "description": "id类型 1=用户id 2=组id", - "$ref": "#/definitions/script_entity.AccessType" + "description": "邀请类型 1=权限邀请码 2=群组邀请码", + "$ref": "#/definitions/script_entity.InviteType" } } }, - "script.AccessListResponse": { + "script.InviteCodeListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/script.Access" + "$ref": "#/definitions/script.InviteCode" } }, "total": { @@ -10765,260 +12132,283 @@ } } }, - "script.AddGroupAccessRequest": { + "script.LastScoreResponse": { "type": "object", "properties": { - "expiretime,default=0": { - "description": "0 为永久", - "type": "integer" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/script.Script" + } }, - "group_id": { + "total": { "type": "integer" - }, - "role": { - "description": "访问权限 guest=访客 manager=管理员", - "$ref": "#/definitions/script_entity.AccessRole" } } }, - "script.AddGroupAccessResponse": { - "type": "object" - }, - "script.AddMemberRequest": { + "script.ListResponse": { "type": "object", "properties": { - "expiretime": { - "type": "integer" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/script.Script" + } }, - "user_id": { + "total": { "type": "integer" } } }, - "script.AddMemberResponse": { + "script.MigrateEsRequest": { "type": "object" }, - "script.AddUserAccessRequest": { + "script.MigrateEsResponse": { + "type": "object" + }, + "script.PutScoreRequest": { "type": "object", "properties": { - "expiretime,default=0": { - "description": "0 为永久", - "type": "integer" - }, - "role": { - "description": "访问权限 guest=访客 manager=管理员", - "$ref": "#/definitions/script_entity.AccessRole" + "message": { + "type": "string" }, - "user_id": { + "score": { "type": "integer" } } }, - "script.AddUserAccessResponse": { - "type": "object" - }, - "script.ArchiveRequest": { + "script.PutScoreResponse": { "type": "object", "properties": { - "archive": { - "type": "boolean" + "id": { + "type": "integer" } } }, - "script.ArchiveResponse": { + "script.RecordVisitRequest": { "type": "object" }, - "script.AuditInviteCodeRequest": { + "script.RecordVisitResponse": { + "type": "object" + }, + "script.RemoveMemberRequest": { + "type": "object" + }, + "script.RemoveMemberResponse": { + "type": "object" + }, + "script.ReplyScoreRequest": { "type": "object", "properties": { - "status": { - "description": "1=通过 2=拒绝", + "commentID": { "type": "integer" + }, + "message": { + "type": "string" } } }, - "script.AuditInviteCodeResponse": { + "script.ReplyScoreResponse": { "type": "object" }, - "script.CategoryListItem": { + "script.Score": { "type": "object", "properties": { + "author_message": { + "type": "string" + }, + "author_message_createtime": { + "type": "integer" + }, "createtime": { "type": "integer" }, "id": { "type": "integer" }, - "name": { + "message": { "type": "string" }, - "num": { - "description": "本分类下脚本数量", + "score": { "type": "integer" }, - "sort": { + "script_id": { "type": "integer" }, - "type": { - "description": "1:脚本分类 2:脚本标签", - "$ref": "#/definitions/script_entity.ScriptCategoryType" + "state": { + "type": "integer" }, "updatetime": { "type": "integer" } } }, - "script.CategoryListResponse": { + "script.ScoreListResponse": { "type": "object", "properties": { - "categories": { - "description": "分类列表", + "list": { "type": "array", "items": { - "$ref": "#/definitions/script.CategoryListItem" + "type": "object", + "$ref": "#/definitions/script.Score" + } + }, + "total": { + "type": "integer" + } + } + }, + "script.ScoreStateResponse": { + "type": "object", + "properties": { + "score_group": { + "description": "每个评分的数量", + "type": "object", + "additionalProperties": { + "type": "integer" } + }, + "score_user_count": { + "description": "评分人数", + "type": "integer" } } }, - "script.Code": { + "script.Script": { "type": "object", "properties": { - "changelog": { - "type": "string" + "archive": { + "type": "integer" }, - "code,omitempty": { - "type": "string" + "category": { + "$ref": "#/definitions/script.CategoryListItem" }, "createtime": { "type": "integer" }, - "definition,omitempty": { + "danger": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "enable_pre_release": { + "$ref": "#/definitions/script_entity.EnablePreRelease" + }, + "id": { + "type": "integer" + }, + "name": { "type": "string" }, - "id": { + "post_id": { + "type": "integer" + }, + "public": { + "type": "integer" + }, + "score": { + "type": "integer" + }, + "score_num": { + "type": "integer" + }, + "script": { + "$ref": "#/definitions/script.Code" + }, + "status": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/script.CategoryListItem" + } + }, + "today_install": { "type": "integer" }, - "is_pre_release": { - "$ref": "#/definitions/script_entity.EnablePreRelease" - }, - "meta,omitempty": { - "type": "string" + "total_install": { + "type": "integer" }, - "meta_json": { - "type": "object" + "type": { + "$ref": "#/definitions/script_entity.Type" }, - "script_id": { + "unwell": { "type": "integer" }, - "status": { + "updatetime": { "type": "integer" - }, - "version": { - "type": "string" } } }, - "script.CodeResponse": { - "type": "object", - "properties": { - "content": { - "type": "string" - } - } + "script.SelfScoreResponse": { + "type": "object" }, - "script.CreateFolderRequest": { + "script.StateResponse": { "type": "object", "properties": { - "description": { - "type": "string" + "favorite_count": { + "description": "收藏人数", + "type": "integer" }, - "name": { - "type": "string" + "favorite_ids": { + "description": "收藏夹", + "type": "array", + "items": { + "type": "integer" + } }, - "private": { - "description": "1私密 2公开", - "type": "integer" - } - } - }, - "script.CreateFolderResponse": { - "type": "object", - "properties": { - "id": { + "issue_count": { + "description": "Issue数量", "type": "integer" - } - } - }, - "script.CreateGroupInviteCodeRequest": { - "type": "object", - "properties": { - "audit": { - "type": "boolean" }, - "count": { + "report_count": { + "description": "未解决举报数量", "type": "integer" }, - "days": { - "description": "0 为永久", + "watch": { + "$ref": "#/definitions/script_entity.ScriptWatchLevel" + }, + "watch_count": { + "description": "关注人数", "type": "integer" } } }, - "script.CreateGroupInviteCodeResponse": { - "type": "object", - "properties": { - "code": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "script.CreateGroupRequest": { + "script.UnfavoriteScriptRequest": { "type": "object", "properties": { - "description": { - "type": "string" - }, - "name": { - "type": "string" + "script_id": { + "type": "integer" } } }, - "script.CreateGroupResponse": { + "script.UnfavoriteScriptResponse": { "type": "object" }, - "script.CreateInviteCodeRequest": { + "script.UpdateAccessRequest": { "type": "object", "properties": { - "audit": { - "type": "boolean" - }, - "count": { - "type": "integer" - }, - "days": { + "expiretime,default=0": { "description": "0 为永久", "type": "integer" + }, + "role": { + "description": "访问权限 guest=访客 manager=管理员", + "$ref": "#/definitions/script_entity.AccessRole" } } }, - "script.CreateInviteCodeResponse": { - "type": "object", - "properties": { - "code": { - "type": "array", - "items": { - "type": "string" - } - } - } + "script.UpdateAccessResponse": { + "type": "object" }, - "script.CreateRequest": { + "script.UpdateCodeRequest": { "type": "object", "properties": { - "category": { + "category_id": { "description": "分类ID", "type": "integer" }, @@ -11034,15 +12424,8 @@ "definition": { "type": "string" }, - "description": { - "type": "string" - }, - "name": { - "type": "string" - }, - "public": { - "description": "公开类型:1 公开 2 半公开 3 私有", - "$ref": "#/definitions/script_entity.Public" + "is_pre_release": { + "$ref": "#/definitions/script_entity.EnablePreRelease" }, "tags": { "description": "标签,只有脚本类型为库时才有意义", @@ -11051,76 +12434,30 @@ "type": "string" } }, - "type": { - "description": "脚本类型:1 用户脚本 2 订阅脚本(不支持) 3 脚本引用库", - "$ref": "#/definitions/script_entity.Type" - }, - "unwell": { - "description": "不适内容: 1 不适 2 适用", - "$ref": "#/definitions/script_entity.UnwellContent" - }, "version": { + "description": "Name string `form:\"name\" binding:\"max=128\" label:\"库的名字\"`\nDescription string `form:\"description\" binding:\"max=102400\" label:\"库的描述\"`", "type": "string" } } }, - "script.CreateResponse": { - "type": "object", - "properties": { - "id": { - "type": "integer" - } - } - }, - "script.DelScoreRequest": { - "type": "object" - }, - "script.DelScoreResponse": { - "type": "object" - }, - "script.DeleteAccessRequest": { - "type": "object" - }, - "script.DeleteAccessResponse": { - "type": "object" - }, - "script.DeleteCodeRequest": { - "type": "object" - }, - "script.DeleteCodeResponse": { - "type": "object" - }, - "script.DeleteFolderRequest": { - "type": "object" - }, - "script.DeleteFolderResponse": { - "type": "object" - }, - "script.DeleteGroupRequest": { - "type": "object" - }, - "script.DeleteGroupResponse": { - "type": "object" - }, - "script.DeleteInviteCodeRequest": { - "type": "object" - }, - "script.DeleteInviteCodeResponse": { + "script.UpdateCodeResponse": { "type": "object" }, - "script.DeleteRequest": { + "script.UpdateCodeSettingRequest": { "type": "object", "properties": { - "reason": { - "description": "删除原因(可选)", + "changelog": { "type": "string" + }, + "is_pre_release": { + "$ref": "#/definitions/script_entity.EnablePreRelease" } } }, - "script.DeleteResponse": { + "script.UpdateCodeSettingResponse": { "type": "object" }, - "script.EditFolderRequest": { + "script.UpdateGroupRequest": { "type": "object", "properties": { "description": { @@ -11128,88 +12465,77 @@ }, "name": { "type": "string" - }, - "private": { - "description": "1私密 2公开", - "type": "integer" } } }, - "script.EditFolderResponse": { - "type": "object" - }, - "script.FavoriteFolderDetailResponse": { + "script.UpdateGroupResponse": { "type": "object" }, - "script.FavoriteFolderItem": { + "script.UpdateLibInfoRequest": { "type": "object", "properties": { - "count": { - "description": "收藏夹中脚本数量", - "type": "integer" - }, "description": { - "description": "收藏夹描述", "type": "string" }, - "id": { - "type": "integer" - }, "name": { "type": "string" - }, - "private": { - "description": "收藏夹类型 1私密 2公开", - "type": "integer" - }, - "updatetime": { - "description": "收藏夹更新时间", + } + } + }, + "script.UpdateLibInfoResponse": { + "type": "object" + }, + "script.UpdateMemberRequest": { + "type": "object", + "properties": { + "expiretime": { "type": "integer" } } }, - "script.FavoriteFolderListResponse": { + "script.UpdateMemberResponse": { + "type": "object" + }, + "script.UpdateScriptGrayRequest": { "type": "object", "properties": { - "list": { + "enable_pre_release": { + "$ref": "#/definitions/script_entity.EnablePreRelease" + }, + "gray_controls": { "type": "array", "items": { - "type": "object", - "$ref": "#/definitions/script.FavoriteFolderItem" + "$ref": "#/definitions/script_entity.GrayControl" } - }, - "total": { - "type": "integer" } } }, - "script.FavoriteScriptListResponse": { + "script.UpdateScriptGrayResponse": { + "type": "object" + }, + "script.UpdateScriptPublicRequest": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/script.Script" - } - }, - "total": { - "type": "integer" + "public": { + "$ref": "#/definitions/script_entity.Public" } } }, - "script.FavoriteScriptRequest": { + "script.UpdateScriptPublicResponse": { + "type": "object" + }, + "script.UpdateScriptUnwellRequest": { "type": "object", "properties": { - "script_id": { - "type": "integer" + "unwell": { + "$ref": "#/definitions/script_entity.UnwellContent" } } }, - "script.FavoriteScriptResponse": { + "script.UpdateScriptUnwellResponse": { "type": "object" }, - "script.GetSettingResponse": { + "script.UpdateSettingRequest": { "type": "object", "properties": { "content_url": { @@ -11218,14 +12544,11 @@ "definition_url": { "type": "string" }, - "enable_pre_release": { - "$ref": "#/definitions/script_entity.EnablePreRelease" + "description": { + "type": "string" }, - "gray_controls": { - "type": "array", - "items": { - "$ref": "#/definitions/script_entity.GrayControl" - } + "name": { + "type": "string" }, "sync_mode": { "$ref": "#/definitions/script_entity.SyncMode" @@ -11235,55 +12558,40 @@ } } }, - "script.Group": { + "script.UpdateSettingResponse": { + "type": "object" + }, + "script.UpdateSyncSettingRequest": { "type": "object", "properties": { - "createtime": { - "type": "integer" - }, - "description": { + "content_url": { "type": "string" }, - "id": { - "type": "integer" - }, - "member": { - "type": "array", - "items": { - "$ref": "#/definitions/script.GroupMember" - } + "definition_url": { + "type": "string" }, - "member_count": { - "type": "integer" + "sync_mode": { + "$ref": "#/definitions/script_entity.SyncMode" }, - "name": { + "sync_url": { "type": "string" } } }, - "script.GroupInviteCodeListResponse": { - "type": "object", - "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/script.InviteCode" - } - }, - "total": { - "type": "integer" - } - } + "script.UpdateSyncSettingResponse": { + "type": "object" }, - "script.GroupListResponse": { + "script.VersionCodeResponse": { + "type": "object" + }, + "script.VersionListResponse": { "type": "object", "properties": { "list": { "type": "array", "items": { "type": "object", - "$ref": "#/definitions/script.Group" + "$ref": "#/definitions/script.Code" } }, "total": { @@ -11291,840 +12599,689 @@ } } }, - "script.GroupMember": { + "script.VersionStatResponse": { "type": "object", "properties": { - "avatar": { - "type": "string" - }, - "createtime": { - "type": "integer" - }, - "expiretime": { - "type": "integer" - }, - "id": { + "pre_release_num": { + "description": "预发布版本数量", "type": "integer" }, - "invite_status": { - "$ref": "#/definitions/script_entity.AccessInviteStatus" - }, - "is_expire": { - "type": "boolean" - }, - "user_id": { + "release_num": { + "description": "正式版本数量", "type": "integer" - }, - "username": { - "type": "string" } } }, - "script.GroupMemberListResponse": { + "script.WatchRequest": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/script.GroupMember" - } - }, - "total": { - "type": "integer" + "watch": { + "$ref": "#/definitions/script_entity.ScriptWatchLevel" } } }, - "script.InfoResponse": { - "type": "object", - "properties": { - "content": { - "type": "string" - }, - "role": { - "$ref": "#/definitions/script_entity.AccessRole" - }, - "sri,omitempty": { - "description": "如果是库的话,会返回sha512 sri", - "type": "string" - } - } + "script.WatchResponse": { + "type": "object" }, - "script.InviteCode": { + "script.WebhookRequest": { "type": "object", "properties": { - "code": { - "description": "邀请码", + "UA": { "type": "string" }, - "createtime": { - "type": "integer" - }, - "expiretime": { - "description": "到期时间", - "type": "integer" - }, - "id": { - "type": "integer" - }, - "invite_status": { - "$ref": "#/definitions/script_entity.InviteStatus" - }, - "is_audit": { - "description": "是否需要审核", - "type": "boolean" - }, - "used": { - "description": "使用用户", - "type": "integer" - }, - "username": { - "description": "使用用户名", + "XHubSignature256": { "type": "string" } } }, - "script.InviteCodeInfoAccess": { + "script.WebhookResponse": { "type": "object", "properties": { - "role": { - "description": "访问权限 guest=访客 manager=管理员", - "$ref": "#/definitions/script_entity.AccessRole" + "error_messages": { + "type": "object", + "additionalProperties": { + "type": "string" + } } } }, - "script.InviteCodeInfoGroup": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "name": { - "type": "string" - } - } + "script_entity.AccessInviteStatus": { + "description": "AccessInviteStatus enum type:\n- AccessInviteStatusAccept: 1\n- AccessInviteStatusReject: 2\n- AccessInviteStatusPending: 3", + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] }, - "script.InviteCodeInfoResponse": { - "type": "object", - "properties": { - "access,omitempty": { - "description": "如果type=1, 则返回权限信息", - "$ref": "#/definitions/script.InviteCodeInfoAccess" - }, - "code_type": { - "description": "邀请码类型 1=邀请码 2=邀请链接", - "$ref": "#/definitions/script_entity.InviteCodeType" - }, - "group,omitempty": { - "description": "如果type=2, 则返回群组信息", - "$ref": "#/definitions/script.InviteCodeInfoGroup" - }, - "invite_status": { - "description": "使用状态", - "$ref": "#/definitions/script_entity.InviteStatus" - }, - "is_audit": { - "description": "是否需要审核 邀请码类型为邀请链接时,该字段固定为false", - "type": "boolean" - }, - "script": { - "$ref": "#/definitions/script.Script" - }, - "type": { - "description": "邀请类型 1=权限邀请码 2=群组邀请码", - "$ref": "#/definitions/script_entity.InviteType" - } - } + "script_entity.AccessRole": { + "description": "AccessRole enum type:\n- AccessRoleGuest: guest\n- AccessRoleManager: manager\n- AccessRoleOwner: owner", + "type": "string", + "enum": [ + "guest", + "manager", + "owner" + ] }, - "script.InviteCodeListResponse": { + "script_entity.AccessType": { + "description": "AccessType enum type:\n- AccessTypeUser: 1\n- AccessTypeGroup: 2", + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "script_entity.Control": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/script.InviteCode" - } + "params": { + "$ref": "#/definitions/script_entity.GrayControlParams" }, - "total": { - "type": "integer" + "type": { + "$ref": "#/definitions/script_entity.GrayControlType" } } }, - "script.LastScoreResponse": { + "script_entity.EnablePreRelease": { + "description": "EnablePreRelease enum type:\n- EnablePreReleaseScript: 1\n- DisablePreReleaseScript: 2", + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "script_entity.GrayControl": { "type": "object", "properties": { - "list": { + "controls": { "type": "array", "items": { - "type": "object", - "$ref": "#/definitions/script.Script" + "$ref": "#/definitions/script_entity.Control" } }, - "total": { - "type": "integer" + "target_version": { + "type": "string" } } }, - "script.ListResponse": { + "script_entity.GrayControlParams": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/script.Script" - } + "cookie_regex": { + "type": "string" }, - "total": { + "weight": { "type": "integer" + }, + "weight_day": { + "type": "number" } } }, - "script.MigrateEsRequest": { - "type": "object" + "script_entity.GrayControlType": { + "description": "GrayControlType enum type:\n- GrayControlTypeWeight: weight\n- GrayControlTypeCookie: cookie\n- GrayControlTypePreRelease: pre-release", + "type": "string", + "enum": [ + "weight", + "cookie", + "pre-release" + ] }, - "script.MigrateEsResponse": { - "type": "object" + "script_entity.InviteCodeType": { + "description": "InviteCodeType enum type:\n- InviteCodeTypeCode: 1\n- InviteCodeTypeLink: 2", + "type": "integer", + "enum": [ + 1, + 2 + ] }, - "script.PutScoreRequest": { + "script_entity.InviteStatus": { + "description": "InviteStatus enum type:\n- InviteStatusUnused: 1\n- InviteStatusUsed: 2\n- InviteStatusExpired: 3\n- InviteStatusPending: 4\n- InviteStatusReject: 5", + "type": "integer", + "enum": [ + 1, + 2, + 3, + 4, + 5 + ] + }, + "script_entity.InviteType": { + "description": "InviteType enum type:\n- InviteTypeAccess: 1\n- InviteTypeGroup: 2", + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "script_entity.Public": { + "description": "Public enum type:\n- PublicScript: 1\n- UnPublicScript: 2\n- PrivateScript: 3", + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + }, + "script_entity.ScriptCategoryType": { + "description": "ScriptCategoryType enum type:\n- ScriptCategoryTypeCategory: 1\n- ScriptCategoryTypeTag: 2", + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "script_entity.ScriptWatchLevel": { + "description": "ScriptWatchLevel enum type:\n- ScriptWatchLevelNone: 0\n- ScriptWatchLevelVersion: 1\n- ScriptWatchLevelIssue: 2\n- ScriptWatchLevelIssueComment: 3", + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3 + ] + }, + "script_entity.SyncMode": { + "description": "SyncMode enum type:\n- SyncModeAuto: 1\n- SyncModeManual: 2", + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "script_entity.Type": { + "description": "Type enum type:\n- UserscriptType: 1\n- SubscribeType: 2\n- LibraryType: 3", + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + }, + "script_entity.UnwellContent": { + "description": "UnwellContent enum type:\n- Unwell: 1\n- Well: 2", + "type": "integer", + "enum": [ + 1, + 2 + ] + }, + "similarity.AddIntegrityWhitelistRequest": { "type": "object", "properties": { - "message": { + "reason": { "type": "string" }, - "score": { + "script_id": { "type": "integer" } } }, - "script.PutScoreResponse": { + "similarity.AddIntegrityWhitelistResponse": { + "type": "object" + }, + "similarity.AddPairWhitelistRequest": { "type": "object", "properties": { - "id": { - "type": "integer" + "reason": { + "type": "string" } } }, - "script.RecordVisitRequest": { - "type": "object" - }, - "script.RecordVisitResponse": { - "type": "object" - }, - "script.RemoveMemberRequest": { + "similarity.AddPairWhitelistResponse": { "type": "object" }, - "script.RemoveMemberResponse": { - "type": "object" - }, - "script.ReplyScoreRequest": { + "similarity.AdminActions": { "type": "object", "properties": { - "commentID": { - "type": "integer" - }, - "message": { - "type": "string" + "can_whitelist": { + "type": "boolean" } } }, - "script.ReplyScoreResponse": { - "type": "object" - }, - "script.Score": { + "similarity.GetEvidencePairResponse": { "type": "object", "properties": { - "author_message": { - "type": "string" - }, - "author_message_createtime": { - "type": "integer" - }, - "createtime": { - "type": "integer" - }, - "id": { - "type": "integer" - }, - "message": { - "type": "string" - }, - "score": { - "type": "integer" - }, - "script_id": { - "type": "integer" - }, - "state": { - "type": "integer" - }, - "updatetime": { - "type": "integer" + "detail": { + "$ref": "#/definitions/similarity.PairDetail" } } }, - "script.ScoreListResponse": { + "similarity.GetIntegrityReviewResponse": { "type": "object", "properties": { - "list": { - "type": "array", - "items": { - "type": "object", - "$ref": "#/definitions/script.Score" - } - }, - "total": { - "type": "integer" + "detail": { + "$ref": "#/definitions/similarity.IntegrityReviewDetail" } } }, - "script.ScoreStateResponse": { + "similarity.GetPairDetailResponse": { "type": "object", "properties": { - "score_group": { - "description": "每个评分的数量", - "type": "object", - "additionalProperties": { - "type": "integer" - } - }, - "score_user_count": { - "description": "评分人数", - "type": "integer" + "detail": { + "$ref": "#/definitions/similarity.PairDetail" } } }, - "script.Script": { + "similarity.IntegrityHitSignal": { "type": "object", "properties": { - "archive": { - "type": "integer" - }, - "category": { - "$ref": "#/definitions/script.CategoryListItem" - }, - "createtime": { - "type": "integer" - }, - "danger": { - "type": "integer" - }, - "description": { - "type": "string" - }, - "enable_pre_release": { - "$ref": "#/definitions/script_entity.EnablePreRelease" - }, - "id": { - "type": "integer" - }, "name": { "type": "string" }, - "post_id": { - "type": "integer" - }, - "public": { - "type": "integer" - }, - "score": { - "type": "integer" - }, - "score_num": { - "type": "integer" - }, - "script": { - "$ref": "#/definitions/script.Code" + "threshold": { + "type": "number" }, - "status": { - "type": "integer" + "value": { + "type": "number" + } + } + }, + "similarity.IntegrityReviewDetail": { + "type": "object", + "properties": { + "code": { + "type": "string" }, - "tags": { + "hit_signals": { "type": "array", "items": { - "$ref": "#/definitions/script.CategoryListItem" + "$ref": "#/definitions/similarity.IntegrityHitSignal" } }, - "today_install": { - "type": "integer" + "review_note,omitempty": { + "type": "string" }, - "total_install": { + "reviewed_at,omitempty": { "type": "integer" }, - "type": { - "$ref": "#/definitions/script_entity.Type" - }, - "unwell": { + "reviewed_by,omitempty": { "type": "integer" }, - "updatetime": { - "type": "integer" + "sub_scores": { + "$ref": "#/definitions/similarity.IntegritySubScores" } } }, - "script.SelfScoreResponse": { - "type": "object" - }, - "script.StateResponse": { + "similarity.IntegrityReviewItem": { "type": "object", "properties": { - "favorite_count": { - "description": "收藏人数", + "createtime": { "type": "integer" }, - "favorite_ids": { - "description": "收藏夹", - "type": "array", - "items": { - "type": "integer" - } - }, - "issue_count": { - "description": "Issue数量", + "id": { "type": "integer" }, - "report_count": { - "description": "未解决举报数量", - "type": "integer" + "score": { + "type": "number" }, - "watch": { - "$ref": "#/definitions/script_entity.ScriptWatchLevel" + "script": { + "$ref": "#/definitions/similarity.ScriptBrief" }, - "watch_count": { - "description": "关注人数", + "script_code_id": { "type": "integer" - } - } - }, - "script.UnfavoriteScriptRequest": { - "type": "object", - "properties": { - "script_id": { + }, + "status": { "type": "integer" } } }, - "script.UnfavoriteScriptResponse": { - "type": "object" - }, - "script.UpdateAccessRequest": { + "similarity.IntegritySubScores": { "type": "object", "properties": { - "expiretime,default=0": { - "description": "0 为永久", - "type": "integer" + "cat_a": { + "type": "number" }, - "role": { - "description": "访问权限 guest=访客 manager=管理员", - "$ref": "#/definitions/script_entity.AccessRole" + "cat_b": { + "type": "number" + }, + "cat_c": { + "type": "number" + }, + "cat_d": { + "type": "number" } } }, - "script.UpdateAccessResponse": { - "type": "object" - }, - "script.UpdateCodeRequest": { + "similarity.IntegrityWhitelistItem": { "type": "object", "properties": { - "category_id": { - "description": "分类ID", + "added_by": { "type": "integer" }, - "changelog": { + "added_by_name": { "type": "string" }, - "code": { - "type": "string" + "createtime": { + "type": "integer" }, - "content": { - "type": "string" + "id": { + "type": "integer" }, - "definition": { + "reason": { "type": "string" }, - "is_pre_release": { - "$ref": "#/definitions/script_entity.EnablePreRelease" - }, - "tags": { - "description": "标签,只有脚本类型为库时才有意义", - "type": "array", - "items": { - "type": "string" - } - }, - "version": { - "description": "Name string `form:\"name\" binding:\"max=128\" label:\"库的名字\"`\nDescription string `form:\"description\" binding:\"max=102400\" label:\"库的描述\"`", - "type": "string" + "script": { + "$ref": "#/definitions/similarity.ScriptBrief" } } }, - "script.UpdateCodeResponse": { - "type": "object" - }, - "script.UpdateCodeSettingRequest": { + "similarity.ListIntegrityReviewsResponse": { "type": "object", "properties": { - "changelog": { - "type": "string" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/similarity.IntegrityReviewItem" + } }, - "is_pre_release": { - "$ref": "#/definitions/script_entity.EnablePreRelease" + "total": { + "type": "integer" } } }, - "script.UpdateCodeSettingResponse": { - "type": "object" - }, - "script.UpdateGroupRequest": { + "similarity.ListIntegrityWhitelistResponse": { "type": "object", "properties": { - "description": { - "type": "string" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/similarity.IntegrityWhitelistItem" + } }, - "name": { - "type": "string" + "total": { + "type": "integer" } } }, - "script.UpdateGroupResponse": { - "type": "object" - }, - "script.UpdateLibInfoRequest": { + "similarity.ListPairWhitelistResponse": { "type": "object", "properties": { - "description": { - "type": "string" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/similarity.PairWhitelistItem" + } }, - "name": { - "type": "string" - } - } - }, - "script.UpdateLibInfoResponse": { - "type": "object" - }, - "script.UpdateMemberRequest": { - "type": "object", - "properties": { - "expiretime": { + "total": { "type": "integer" } } }, - "script.UpdateMemberResponse": { - "type": "object" - }, - "script.UpdateScriptGrayRequest": { + "similarity.ListPairsResponse": { "type": "object", "properties": { - "enable_pre_release": { - "$ref": "#/definitions/script_entity.EnablePreRelease" - }, - "gray_controls": { + "list": { "type": "array", "items": { - "$ref": "#/definitions/script_entity.GrayControl" + "type": "object", + "$ref": "#/definitions/similarity.SimilarPairItem" } + }, + "total": { + "type": "integer" } } }, - "script.UpdateScriptGrayResponse": { - "type": "object" - }, - "script.UpdateScriptPublicRequest": { + "similarity.ListSuspectsResponse": { "type": "object", "properties": { - "public": { - "$ref": "#/definitions/script_entity.Public" + "list": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/similarity.SuspectScriptItem" + } + }, + "total": { + "type": "integer" } } }, - "script.UpdateScriptPublicResponse": { - "type": "object" - }, - "script.UpdateScriptUnwellRequest": { + "similarity.MatchSegment": { "type": "object", "properties": { - "unwell": { - "$ref": "#/definitions/script_entity.UnwellContent" + "a_end": { + "type": "integer" + }, + "a_start": { + "type": "integer" + }, + "b_end": { + "type": "integer" + }, + "b_start": { + "type": "integer" } } }, - "script.UpdateScriptUnwellResponse": { - "type": "object" - }, - "script.UpdateSettingRequest": { + "similarity.PairDetail": { "type": "object", "properties": { - "content_url": { - "type": "string" + "a_fp_count": { + "type": "integer" }, - "definition_url": { - "type": "string" + "admin_actions,omitempty": { + "$ref": "#/definitions/similarity.AdminActions" }, - "description": { + "b_fp_count": { + "type": "integer" + }, + "code_a": { "type": "string" }, - "name": { + "code_b": { "type": "string" }, - "sync_mode": { - "$ref": "#/definitions/script_entity.SyncMode" + "common_count": { + "type": "integer" }, - "sync_url": { - "type": "string" - } - } - }, - "script.UpdateSettingResponse": { - "type": "object" - }, - "script.UpdateSyncSettingRequest": { - "type": "object", - "properties": { - "content_url": { - "type": "string" + "detected_at": { + "type": "integer" }, - "definition_url": { + "earlier_side": { "type": "string" }, - "sync_mode": { - "$ref": "#/definitions/script_entity.SyncMode" + "id": { + "type": "integer" }, - "sync_url": { - "type": "string" - } - } - }, - "script.UpdateSyncSettingResponse": { - "type": "object" - }, - "script.VersionCodeResponse": { - "type": "object" - }, - "script.VersionListResponse": { - "type": "object", - "properties": { - "list": { + "jaccard": { + "type": "number" + }, + "match_segments": { "type": "array", "items": { - "type": "object", - "$ref": "#/definitions/script.Code" + "$ref": "#/definitions/similarity.MatchSegment" } }, - "total": { + "review_note,omitempty": { + "type": "string" + }, + "script_a": { + "$ref": "#/definitions/similarity.ScriptFullInfo" + }, + "script_b": { + "$ref": "#/definitions/similarity.ScriptFullInfo" + }, + "status,omitempty": { + "description": "Admin-only fields (omitted on evidence page)", "type": "integer" } } }, - "script.VersionStatResponse": { + "similarity.PairWhitelistItem": { "type": "object", "properties": { - "pre_release_num": { - "description": "预发布版本数量", + "added_by": { + "type": "integer" + }, + "added_by_name": { + "type": "string" + }, + "createtime": { "type": "integer" }, - "release_num": { - "description": "正式版本数量", + "id": { "type": "integer" + }, + "reason": { + "type": "string" + }, + "script_a": { + "$ref": "#/definitions/similarity.ScriptBrief" + }, + "script_b": { + "$ref": "#/definitions/similarity.ScriptBrief" } } }, - "script.WatchRequest": { + "similarity.RemoveIntegrityWhitelistRequest": { + "type": "object" + }, + "similarity.RemoveIntegrityWhitelistResponse": { + "type": "object" + }, + "similarity.RemovePairWhitelistRequest": { + "type": "object" + }, + "similarity.RemovePairWhitelistResponse": { + "type": "object" + }, + "similarity.ResolveIntegrityReviewRequest": { "type": "object", "properties": { - "watch": { - "$ref": "#/definitions/script_entity.ScriptWatchLevel" + "note": { + "type": "string" + }, + "status": { + "type": "integer" } } }, - "script.WatchResponse": { + "similarity.ResolveIntegrityReviewResponse": { "type": "object" }, - "script.WebhookRequest": { + "similarity.ScriptBrief": { "type": "object", "properties": { - "UA": { + "createtime": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "name": { "type": "string" }, - "XHubSignature256": { + "public": { + "description": "1=public 2=unlisted 3=private", + "type": "integer" + }, + "user_id": { + "type": "integer" + }, + "username": { "type": "string" } } }, - "script.WebhookResponse": { + "similarity.ScriptFullInfo": { "type": "object", "properties": { - "error_messages": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "code_created_at": { + "type": "integer" + }, + "script_code_id": { + "type": "integer" + }, + "version": { + "type": "string" } } }, - "script_entity.AccessInviteStatus": { - "description": "AccessInviteStatus enum type:\n- AccessInviteStatusAccept: 1\n- AccessInviteStatusReject: 2\n- AccessInviteStatusPending: 3", - "type": "integer", - "enum": [ - 1, - 2, - 3 - ] - }, - "script_entity.AccessRole": { - "description": "AccessRole enum type:\n- AccessRoleGuest: guest\n- AccessRoleManager: manager\n- AccessRoleOwner: owner", - "type": "string", - "enum": [ - "guest", - "manager", - "owner" - ] - }, - "script_entity.AccessType": { - "description": "AccessType enum type:\n- AccessTypeUser: 1\n- AccessTypeGroup: 2", - "type": "integer", - "enum": [ - 1, - 2 - ] - }, - "script_entity.Control": { + "similarity.SimilarPairItem": { "type": "object", "properties": { - "params": { - "$ref": "#/definitions/script_entity.GrayControlParams" + "common_count": { + "type": "integer" }, - "type": { - "$ref": "#/definitions/script_entity.GrayControlType" + "detected_at": { + "type": "integer" + }, + "earlier_side": { + "description": "\"A\" | \"B\" | \"same\"", + "type": "string" + }, + "id": { + "type": "integer" + }, + "integrity_score,omitempty": { + "description": "§10.12, 0 if not available", + "type": "number" + }, + "jaccard": { + "type": "number" + }, + "script_a": { + "$ref": "#/definitions/similarity.ScriptBrief" + }, + "script_b": { + "$ref": "#/definitions/similarity.ScriptBrief" + }, + "status": { + "type": "integer" } } }, - "script_entity.EnablePreRelease": { - "description": "EnablePreRelease enum type:\n- EnablePreReleaseScript: 1\n- DisablePreReleaseScript: 2", - "type": "integer", - "enum": [ - 1, - 2 - ] - }, - "script_entity.GrayControl": { + "similarity.SuspectScriptItem": { "type": "object", "properties": { - "controls": { + "coverage": { + "type": "number" + }, + "detected_at": { + "type": "integer" + }, + "integrity_score,omitempty": { + "type": "number" + }, + "max_jaccard": { + "type": "number" + }, + "pair_count": { + "type": "integer" + }, + "script": { + "$ref": "#/definitions/similarity.ScriptBrief" + }, + "top_sources": { "type": "array", "items": { - "$ref": "#/definitions/script_entity.Control" + "$ref": "#/definitions/similarity.TopSource" } - }, - "target_version": { - "type": "string" } } }, - "script_entity.GrayControlParams": { + "similarity.TopSource": { "type": "object", "properties": { - "cookie_regex": { - "type": "string" + "contribution_pct": { + "type": "number" }, - "weight": { + "jaccard": { + "type": "number" + }, + "script_id": { "type": "integer" }, - "weight_day": { - "type": "number" + "script_name": { + "type": "string" } } }, - "script_entity.GrayControlType": { - "description": "GrayControlType enum type:\n- GrayControlTypeWeight: weight\n- GrayControlTypeCookie: cookie\n- GrayControlTypePreRelease: pre-release", - "type": "string", - "enum": [ - "weight", - "cookie", - "pre-release" - ] - }, - "script_entity.InviteCodeType": { - "description": "InviteCodeType enum type:\n- InviteCodeTypeCode: 1\n- InviteCodeTypeLink: 2", - "type": "integer", - "enum": [ - 1, - 2 - ] - }, - "script_entity.InviteStatus": { - "description": "InviteStatus enum type:\n- InviteStatusUnused: 1\n- InviteStatusUsed: 2\n- InviteStatusExpired: 3\n- InviteStatusPending: 4\n- InviteStatusReject: 5", - "type": "integer", - "enum": [ - 1, - 2, - 3, - 4, - 5 - ] - }, - "script_entity.InviteType": { - "description": "InviteType enum type:\n- InviteTypeAccess: 1\n- InviteTypeGroup: 2", - "type": "integer", - "enum": [ - 1, - 2 - ] - }, - "script_entity.Public": { - "description": "Public enum type:\n- PublicScript: 1\n- UnPublicScript: 2\n- PrivateScript: 3", - "type": "integer", - "enum": [ - 1, - 2, - 3 - ] - }, - "script_entity.ScriptCategoryType": { - "description": "ScriptCategoryType enum type:\n- ScriptCategoryTypeCategory: 1\n- ScriptCategoryTypeTag: 2", - "type": "integer", - "enum": [ - 1, - 2 - ] - }, - "script_entity.ScriptWatchLevel": { - "description": "ScriptWatchLevel enum type:\n- ScriptWatchLevelNone: 0\n- ScriptWatchLevelVersion: 1\n- ScriptWatchLevelIssue: 2\n- ScriptWatchLevelIssueComment: 3", - "type": "integer", - "enum": [ - 0, - 1, - 2, - 3 - ] - }, - "script_entity.SyncMode": { - "description": "SyncMode enum type:\n- SyncModeAuto: 1\n- SyncModeManual: 2", - "type": "integer", - "enum": [ - 1, - 2 - ] - }, - "script_entity.Type": { - "description": "Type enum type:\n- UserscriptType: 1\n- SubscribeType: 2\n- LibraryType: 3", - "type": "integer", - "enum": [ - 1, - 2, - 3 - ] - }, - "script_entity.UnwellContent": { - "description": "UnwellContent enum type:\n- Unwell: 1\n- Well: 2", - "type": "integer", - "enum": [ - 1, - 2 - ] - }, "statistics.Chart": { "type": "object", "properties": { @@ -12343,6 +13500,13 @@ "$ref": "#/definitions/user.BadgeItem" } }, + "ban_expire_at,omitempty": { + "type": "integer" + }, + "ban_reason,omitempty": { + "description": "以下字段仅管理员可见", + "type": "string" + }, "description": { "description": "个人简介", "type": "string" @@ -12375,6 +13539,15 @@ "description": "位置", "type": "string" }, + "register_email,omitempty": { + "type": "string" + }, + "register_ip,omitempty": { + "type": "string" + }, + "register_ip_location,omitempty": { + "type": "string" + }, "website": { "description": "个人网站", "type": "string" @@ -12469,6 +13642,23 @@ } } }, + "user.SendEmailCodeRequest": { + "type": "object", + "properties": { + "email": { + "description": "邮箱", + "type": "string" + } + } + }, + "user.SendEmailCodeResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, "user.UpdateConfigRequest": { "type": "object", "properties": { @@ -12503,6 +13693,10 @@ "description": "邮箱", "type": "string" }, + "email_code": { + "description": "邮箱验证码", + "type": "string" + }, "location": { "description": "位置", "type": "string" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 0effc46..43a8758 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -413,6 +413,18 @@ definitions: type: object admin.UpdateUserStatusRequest: properties: + clean_scores: + description: 是否清理用户评分 + type: boolean + clean_scripts: + description: 是否清理用户脚本 + type: boolean + expire_at: + description: 封禁到期时间(unix timestamp), 0=永久 + type: integer + reason: + description: 封禁理由 + type: string status: type: integer type: object @@ -793,6 +805,9 @@ definitions: type: object auth.OIDCRegisterAndBindRequest: properties: + '-': + description: 由 Controller 层设置,不从请求中读取 + type: string agree_terms: type: boolean bind_token: @@ -2451,6 +2466,318 @@ definitions: - 1 - 2 type: integer + similarity.AddIntegrityWhitelistRequest: + properties: + reason: + type: string + script_id: + type: integer + type: object + similarity.AddIntegrityWhitelistResponse: + type: object + similarity.AddPairWhitelistRequest: + properties: + reason: + type: string + type: object + similarity.AddPairWhitelistResponse: + type: object + similarity.AdminActions: + properties: + can_whitelist: + type: boolean + type: object + similarity.GetEvidencePairResponse: + properties: + detail: + $ref: '#/definitions/similarity.PairDetail' + type: object + similarity.GetIntegrityReviewResponse: + properties: + detail: + $ref: '#/definitions/similarity.IntegrityReviewDetail' + type: object + similarity.GetPairDetailResponse: + properties: + detail: + $ref: '#/definitions/similarity.PairDetail' + type: object + similarity.IntegrityHitSignal: + properties: + name: + type: string + threshold: + type: number + value: + type: number + type: object + similarity.IntegrityReviewDetail: + properties: + code: + type: string + hit_signals: + items: + $ref: '#/definitions/similarity.IntegrityHitSignal' + type: array + review_note,omitempty: + type: string + reviewed_at,omitempty: + type: integer + reviewed_by,omitempty: + type: integer + sub_scores: + $ref: '#/definitions/similarity.IntegritySubScores' + type: object + similarity.IntegrityReviewItem: + properties: + createtime: + type: integer + id: + type: integer + score: + type: number + script: + $ref: '#/definitions/similarity.ScriptBrief' + script_code_id: + type: integer + status: + type: integer + type: object + similarity.IntegritySubScores: + properties: + cat_a: + type: number + cat_b: + type: number + cat_c: + type: number + cat_d: + type: number + type: object + similarity.IntegrityWhitelistItem: + properties: + added_by: + type: integer + added_by_name: + type: string + createtime: + type: integer + id: + type: integer + reason: + type: string + script: + $ref: '#/definitions/similarity.ScriptBrief' + type: object + similarity.ListIntegrityReviewsResponse: + properties: + list: + items: + $ref: '#/definitions/similarity.IntegrityReviewItem' + type: object + type: array + total: + type: integer + type: object + similarity.ListIntegrityWhitelistResponse: + properties: + list: + items: + $ref: '#/definitions/similarity.IntegrityWhitelistItem' + type: object + type: array + total: + type: integer + type: object + similarity.ListPairWhitelistResponse: + properties: + list: + items: + $ref: '#/definitions/similarity.PairWhitelistItem' + type: object + type: array + total: + type: integer + type: object + similarity.ListPairsResponse: + properties: + list: + items: + $ref: '#/definitions/similarity.SimilarPairItem' + type: object + type: array + total: + type: integer + type: object + similarity.ListSuspectsResponse: + properties: + list: + items: + $ref: '#/definitions/similarity.SuspectScriptItem' + type: object + type: array + total: + type: integer + type: object + similarity.MatchSegment: + properties: + a_end: + type: integer + a_start: + type: integer + b_end: + type: integer + b_start: + type: integer + type: object + similarity.PairDetail: + properties: + a_fp_count: + type: integer + admin_actions,omitempty: + $ref: '#/definitions/similarity.AdminActions' + b_fp_count: + type: integer + code_a: + type: string + code_b: + type: string + common_count: + type: integer + detected_at: + type: integer + earlier_side: + type: string + id: + type: integer + jaccard: + type: number + match_segments: + items: + $ref: '#/definitions/similarity.MatchSegment' + type: array + review_note,omitempty: + type: string + script_a: + $ref: '#/definitions/similarity.ScriptFullInfo' + script_b: + $ref: '#/definitions/similarity.ScriptFullInfo' + status,omitempty: + description: Admin-only fields (omitted on evidence page) + type: integer + type: object + similarity.PairWhitelistItem: + properties: + added_by: + type: integer + added_by_name: + type: string + createtime: + type: integer + id: + type: integer + reason: + type: string + script_a: + $ref: '#/definitions/similarity.ScriptBrief' + script_b: + $ref: '#/definitions/similarity.ScriptBrief' + type: object + similarity.RemoveIntegrityWhitelistRequest: + type: object + similarity.RemoveIntegrityWhitelistResponse: + type: object + similarity.RemovePairWhitelistRequest: + type: object + similarity.RemovePairWhitelistResponse: + type: object + similarity.ResolveIntegrityReviewRequest: + properties: + note: + type: string + status: + type: integer + type: object + similarity.ResolveIntegrityReviewResponse: + type: object + similarity.ScriptBrief: + properties: + createtime: + type: integer + id: + type: integer + name: + type: string + public: + description: 1=public 2=unlisted 3=private + type: integer + user_id: + type: integer + username: + type: string + type: object + similarity.ScriptFullInfo: + properties: + code_created_at: + type: integer + script_code_id: + type: integer + version: + type: string + type: object + similarity.SimilarPairItem: + properties: + common_count: + type: integer + detected_at: + type: integer + earlier_side: + description: '"A" | "B" | "same"' + type: string + id: + type: integer + integrity_score,omitempty: + description: §10.12, 0 if not available + type: number + jaccard: + type: number + script_a: + $ref: '#/definitions/similarity.ScriptBrief' + script_b: + $ref: '#/definitions/similarity.ScriptBrief' + status: + type: integer + type: object + similarity.SuspectScriptItem: + properties: + coverage: + type: number + detected_at: + type: integer + integrity_score,omitempty: + type: number + max_jaccard: + type: number + pair_count: + type: integer + script: + $ref: '#/definitions/similarity.ScriptBrief' + top_sources: + items: + $ref: '#/definitions/similarity.TopSource' + type: array + type: object + similarity.TopSource: + properties: + contribution_pct: + type: number + jaccard: + type: number + script_id: + type: integer + script_name: + type: string + type: object statistics.Chart: properties: x: @@ -2594,6 +2921,11 @@ definitions: items: $ref: '#/definitions/user.BadgeItem' type: array + ban_expire_at,omitempty: + type: integer + ban_reason,omitempty: + description: 以下字段仅管理员可见 + type: string description: description: 个人简介 type: string @@ -2618,6 +2950,12 @@ definitions: location: description: 位置 type: string + register_email,omitempty: + type: string + register_ip,omitempty: + type: string + register_ip_location,omitempty: + type: string website: description: 个人网站 type: string @@ -2679,6 +3017,17 @@ definitions: message: type: string type: object + user.SendEmailCodeRequest: + properties: + email: + description: 邮箱 + type: string + type: object + user.SendEmailCodeResponse: + properties: + message: + type: string + type: object user.UpdateConfigRequest: properties: notify: @@ -2702,6 +3051,9 @@ definitions: email: description: 邮箱 type: string + email_code: + description: 邮箱验证码 + type: string location: description: 位置 type: string @@ -3032,30 +3384,342 @@ paths: code: type: integer data: - $ref: '#/definitions/admin.CreateOAuthAppResponse' + $ref: '#/definitions/admin.CreateOAuthAppResponse' + msg: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/BadRequest' + summary: 创建 OAuth 应用 + tags: + - admin + /admin/oauth-apps/{id}: + delete: + consumes: + - application/json + description: 删除 OAuth 应用 + parameters: + - in: path + name: id + required: true + type: integer + - in: body + name: body + schema: + $ref: '#/definitions/admin.DeleteOAuthAppRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + code: + type: integer + data: + $ref: '#/definitions/admin.DeleteOAuthAppResponse' + msg: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/BadRequest' + summary: 删除 OAuth 应用 + tags: + - admin + put: + consumes: + - application/json + description: 更新 OAuth 应用 + parameters: + - in: path + name: id + required: true + type: integer + - in: body + name: body + schema: + $ref: '#/definitions/admin.UpdateOAuthAppRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + code: + type: integer + data: + $ref: '#/definitions/admin.UpdateOAuthAppResponse' + msg: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/BadRequest' + summary: 更新 OAuth 应用 + tags: + - admin + /admin/oauth-apps/{id}/secret: + post: + consumes: + - application/json + description: 重置 OAuth 应用密钥 + parameters: + - in: path + name: id + required: true + type: integer + - in: body + name: body + schema: + $ref: '#/definitions/admin.ResetOAuthAppSecretRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + code: + type: integer + data: + $ref: '#/definitions/admin.ResetOAuthAppSecretResponse' + msg: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/BadRequest' + summary: 重置 OAuth 应用密钥 + tags: + - admin + /admin/oidc-providers: + get: + consumes: + - application/json + description: 管理员获取 OIDC 提供商列表 + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + code: + type: integer + data: + $ref: '#/definitions/admin.ListOIDCProvidersResponse' + msg: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/BadRequest' + summary: 管理员获取 OIDC 提供商列表 + tags: + - admin + post: + consumes: + - application/json + description: 创建 OIDC 提供商 + parameters: + - in: body + name: body + schema: + $ref: '#/definitions/admin.CreateOIDCProviderRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + code: + type: integer + data: + $ref: '#/definitions/admin.CreateOIDCProviderResponse' + msg: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/BadRequest' + summary: 创建 OIDC 提供商 + tags: + - admin + /admin/oidc-providers/{id}: + delete: + consumes: + - application/json + description: 删除 OIDC 提供商 + parameters: + - in: path + name: id + required: true + type: integer + - in: body + name: body + schema: + $ref: '#/definitions/admin.DeleteOIDCProviderRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + code: + type: integer + data: + $ref: '#/definitions/admin.DeleteOIDCProviderResponse' + msg: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/BadRequest' + summary: 删除 OIDC 提供商 + tags: + - admin + put: + consumes: + - application/json + description: 更新 OIDC 提供商 + parameters: + - in: path + name: id + required: true + type: integer + - in: body + name: body + schema: + $ref: '#/definitions/admin.UpdateOIDCProviderRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + code: + type: integer + data: + $ref: '#/definitions/admin.UpdateOIDCProviderResponse' + msg: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/BadRequest' + summary: 更新 OIDC 提供商 + tags: + - admin + /admin/oidc-providers/discover: + post: + consumes: + - application/json + description: 通过 .well-known/openid-configuration 发现 OIDC 配置 + parameters: + - in: body + name: body + schema: + $ref: '#/definitions/admin.DiscoverOIDCConfigRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + code: + type: integer + data: + $ref: '#/definitions/admin.DiscoverOIDCConfigResponse' + msg: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/BadRequest' + summary: 通过 .well-known/openid-configuration 发现 OIDC 配置 + tags: + - admin + /admin/reports: + get: + consumes: + - application/json + description: 管理员获取所有举报列表 + parameters: + - description: 0:全部 1:待处理 3:已解决 + in: query + name: status,default=0 + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + code: + type: integer + data: + $ref: '#/definitions/admin.ListReportsResponse' + msg: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/BadRequest' + summary: 管理员获取所有举报列表 + tags: + - admin + /admin/scores: + get: + consumes: + - application/json + description: 管理员获取评分列表 + parameters: + - in: query + name: script_id + type: integer + - in: query + name: keyword + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + code: + type: integer + data: + $ref: '#/definitions/admin.ListScoresResponse' msg: type: string "400": description: Bad Request schema: $ref: '#/definitions/BadRequest' - summary: 创建 OAuth 应用 + summary: 管理员获取评分列表 tags: - admin - /admin/oauth-apps/{id}: - delete: + /admin/scripts: + get: consumes: - application/json - description: 删除 OAuth 应用 + description: 管理员获取脚本列表 parameters: - - in: path - name: id - required: true + - in: query + name: keyword + type: string + - in: query + name: status type: integer - - in: body - name: body - schema: - $ref: '#/definitions/admin.DeleteOAuthAppRequest' produces: - application/json responses: @@ -3066,20 +3730,21 @@ paths: code: type: integer data: - $ref: '#/definitions/admin.DeleteOAuthAppResponse' + $ref: '#/definitions/admin.ListScriptsResponse' msg: type: string "400": description: Bad Request schema: $ref: '#/definitions/BadRequest' - summary: 删除 OAuth 应用 + summary: 管理员获取脚本列表 tags: - admin + /admin/scripts/{id}/restore: put: consumes: - application/json - description: 更新 OAuth 应用 + description: 管理员恢复脚本 parameters: - in: path name: id @@ -3088,7 +3753,7 @@ paths: - in: body name: body schema: - $ref: '#/definitions/admin.UpdateOAuthAppRequest' + $ref: '#/definitions/admin.AdminRestoreScriptRequest' produces: - application/json responses: @@ -3099,21 +3764,21 @@ paths: code: type: integer data: - $ref: '#/definitions/admin.UpdateOAuthAppResponse' + $ref: '#/definitions/admin.AdminRestoreScriptResponse' msg: type: string "400": description: Bad Request schema: $ref: '#/definitions/BadRequest' - summary: 更新 OAuth 应用 + summary: 管理员恢复脚本 tags: - admin - /admin/oauth-apps/{id}/secret: - post: + /admin/scripts/{id}/visibility: + put: consumes: - application/json - description: 重置 OAuth 应用密钥 + description: 管理员修改脚本可见性 parameters: - in: path name: id @@ -3122,7 +3787,7 @@ paths: - in: body name: body schema: - $ref: '#/definitions/admin.ResetOAuthAppSecretRequest' + $ref: '#/definitions/admin.AdminUpdateScriptVisibilityRequest' produces: - application/json responses: @@ -3133,21 +3798,25 @@ paths: code: type: integer data: - $ref: '#/definitions/admin.ResetOAuthAppSecretResponse' + $ref: '#/definitions/admin.AdminUpdateScriptVisibilityResponse' msg: type: string "400": description: Bad Request schema: $ref: '#/definitions/BadRequest' - summary: 重置 OAuth 应用密钥 + summary: 管理员修改脚本可见性 tags: - admin - /admin/oidc-providers: + /admin/similarity/integrity/reviews: get: consumes: - application/json - description: 管理员获取 OIDC 提供商列表 + parameters: + - description: 0=pending 1=ok 2=violated + in: query + name: status + type: integer produces: - application/json responses: @@ -3158,25 +3827,24 @@ paths: code: type: integer data: - $ref: '#/definitions/admin.ListOIDCProvidersResponse' + $ref: '#/definitions/similarity.ListIntegrityReviewsResponse' msg: type: string "400": description: Bad Request schema: $ref: '#/definitions/BadRequest' - summary: 管理员获取 OIDC 提供商列表 tags: - - admin - post: + - similarity + /admin/similarity/integrity/reviews/{id}: + get: consumes: - application/json - description: 创建 OIDC 提供商 parameters: - - in: body - name: body - schema: - $ref: '#/definitions/admin.CreateOIDCProviderRequest' + - in: path + name: id + required: true + type: integer produces: - application/json responses: @@ -3187,21 +3855,19 @@ paths: code: type: integer data: - $ref: '#/definitions/admin.CreateOIDCProviderResponse' + $ref: '#/definitions/similarity.GetIntegrityReviewResponse' msg: type: string "400": description: Bad Request schema: $ref: '#/definitions/BadRequest' - summary: 创建 OIDC 提供商 tags: - - admin - /admin/oidc-providers/{id}: - delete: + - similarity + /admin/similarity/integrity/reviews/{id}/resolve: + post: consumes: - application/json - description: 删除 OIDC 提供商 parameters: - in: path name: id @@ -3210,7 +3876,7 @@ paths: - in: body name: body schema: - $ref: '#/definitions/admin.DeleteOIDCProviderRequest' + $ref: '#/definitions/similarity.ResolveIntegrityReviewRequest' produces: - application/json responses: @@ -3221,29 +3887,19 @@ paths: code: type: integer data: - $ref: '#/definitions/admin.DeleteOIDCProviderResponse' + $ref: '#/definitions/similarity.ResolveIntegrityReviewResponse' msg: type: string "400": description: Bad Request schema: $ref: '#/definitions/BadRequest' - summary: 删除 OIDC 提供商 tags: - - admin - put: + - similarity + /admin/similarity/integrity/whitelist: + get: consumes: - application/json - description: 更新 OIDC 提供商 - parameters: - - in: path - name: id - required: true - type: integer - - in: body - name: body - schema: - $ref: '#/definitions/admin.UpdateOIDCProviderRequest' produces: - application/json responses: @@ -3254,26 +3910,23 @@ paths: code: type: integer data: - $ref: '#/definitions/admin.UpdateOIDCProviderResponse' + $ref: '#/definitions/similarity.ListIntegrityWhitelistResponse' msg: type: string "400": description: Bad Request schema: $ref: '#/definitions/BadRequest' - summary: 更新 OIDC 提供商 tags: - - admin - /admin/oidc-providers/discover: + - similarity post: consumes: - application/json - description: 通过 .well-known/openid-configuration 发现 OIDC 配置 parameters: - in: body name: body schema: - $ref: '#/definitions/admin.DiscoverOIDCConfigRequest' + $ref: '#/definitions/similarity.AddIntegrityWhitelistRequest' produces: - application/json responses: @@ -3284,26 +3937,28 @@ paths: code: type: integer data: - $ref: '#/definitions/admin.DiscoverOIDCConfigResponse' + $ref: '#/definitions/similarity.AddIntegrityWhitelistResponse' msg: type: string "400": description: Bad Request schema: $ref: '#/definitions/BadRequest' - summary: 通过 .well-known/openid-configuration 发现 OIDC 配置 tags: - - admin - /admin/reports: - get: + - similarity + /admin/similarity/integrity/whitelist/{script_id}: + delete: consumes: - application/json - description: 管理员获取所有举报列表 parameters: - - description: 0:全部 1:待处理 3:已解决 - in: query - name: status,default=0 + - in: path + name: script_id + required: true type: integer + - in: body + name: body + schema: + $ref: '#/definitions/similarity.RemoveIntegrityWhitelistRequest' produces: - application/json responses: @@ -3314,28 +3969,30 @@ paths: code: type: integer data: - $ref: '#/definitions/admin.ListReportsResponse' + $ref: '#/definitions/similarity.RemoveIntegrityWhitelistResponse' msg: type: string "400": description: Bad Request schema: $ref: '#/definitions/BadRequest' - summary: 管理员获取所有举报列表 tags: - - admin - /admin/scores: + - similarity + /admin/similarity/pairs: get: consumes: - application/json - description: 管理员获取评分列表 parameters: + - description: nil = any, 0/1/2 = filter + in: query + name: status + type: integer + - in: query + name: min_jaccard + type: number - in: query name: script_id type: integer - - in: query - name: keyword - type: string produces: - application/json responses: @@ -3346,27 +4003,23 @@ paths: code: type: integer data: - $ref: '#/definitions/admin.ListScoresResponse' + $ref: '#/definitions/similarity.ListPairsResponse' msg: type: string "400": description: Bad Request schema: $ref: '#/definitions/BadRequest' - summary: 管理员获取评分列表 tags: - - admin - /admin/scripts: + - similarity + /admin/similarity/pairs/{id}: get: consumes: - application/json - description: 管理员获取脚本列表 parameters: - - in: query - name: keyword - type: string - - in: query - name: status + - in: path + name: id + required: true type: integer produces: - application/json @@ -3378,21 +4031,19 @@ paths: code: type: integer data: - $ref: '#/definitions/admin.ListScriptsResponse' + $ref: '#/definitions/similarity.GetPairDetailResponse' msg: type: string "400": description: Bad Request schema: $ref: '#/definitions/BadRequest' - summary: 管理员获取脚本列表 tags: - - admin - /admin/scripts/{id}/restore: - put: + - similarity + /admin/similarity/pairs/{id}/whitelist: + delete: consumes: - application/json - description: 管理员恢复脚本 parameters: - in: path name: id @@ -3401,7 +4052,7 @@ paths: - in: body name: body schema: - $ref: '#/definitions/admin.AdminRestoreScriptRequest' + $ref: '#/definitions/similarity.RemovePairWhitelistRequest' produces: - application/json responses: @@ -3412,21 +4063,18 @@ paths: code: type: integer data: - $ref: '#/definitions/admin.AdminRestoreScriptResponse' + $ref: '#/definitions/similarity.RemovePairWhitelistResponse' msg: type: string "400": description: Bad Request schema: $ref: '#/definitions/BadRequest' - summary: 管理员恢复脚本 tags: - - admin - /admin/scripts/{id}/visibility: - put: + - similarity + post: consumes: - application/json - description: 管理员修改脚本可见性 parameters: - in: path name: id @@ -3435,7 +4083,7 @@ paths: - in: body name: body schema: - $ref: '#/definitions/admin.AdminUpdateScriptVisibilityRequest' + $ref: '#/definitions/similarity.AddPairWhitelistRequest' produces: - application/json responses: @@ -3446,16 +4094,71 @@ paths: code: type: integer data: - $ref: '#/definitions/admin.AdminUpdateScriptVisibilityResponse' + $ref: '#/definitions/similarity.AddPairWhitelistResponse' msg: type: string "400": description: Bad Request schema: $ref: '#/definitions/BadRequest' - summary: 管理员修改脚本可见性 tags: - - admin + - similarity + /admin/similarity/suspects: + get: + consumes: + - application/json + parameters: + - in: query + name: min_jaccard + type: number + - in: query + name: min_coverage + type: number + - in: query + name: status + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + code: + type: integer + data: + $ref: '#/definitions/similarity.ListSuspectsResponse' + msg: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/BadRequest' + tags: + - similarity + /admin/similarity/whitelist: + get: + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + code: + type: integer + data: + $ref: '#/definitions/similarity.ListPairWhitelistResponse' + msg: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/BadRequest' + tags: + - similarity /admin/system-configs: get: consumes: @@ -7533,6 +8236,34 @@ paths: summary: 全量迁移数据到es tags: - script + /similarity/pair/{id}: + get: + consumes: + - application/json + parameters: + - in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + code: + type: integer + data: + $ref: '#/definitions/similarity.GetEvidencePairResponse' + msg: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/BadRequest' + tags: + - similarity /users: get: consumes: @@ -7924,6 +8655,36 @@ paths: summary: 更新用户信息 tags: - user + /users/email/code: + post: + consumes: + - application/json + description: 发送邮箱验证码 + parameters: + - in: body + name: body + schema: + $ref: '#/definitions/user.SendEmailCodeRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + code: + type: integer + data: + $ref: '#/definitions/user.SendEmailCodeResponse' + msg: + type: string + "400": + description: Bad Request + schema: + $ref: '#/definitions/BadRequest' + summary: 发送邮箱验证码 + tags: + - user /users/logout: get: consumes: diff --git a/go.mod b/go.mod index 493f24f..248a01a 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,16 @@ go 1.25.0 require ( github.com/Masterminds/semver/v3 v3.2.1 github.com/cago-frame/cago v0.0.0-20260225164324-bc709c9c81a3 + github.com/cespare/xxhash/v2 v2.3.0 github.com/coreos/go-oidc/v3 v3.17.0 + github.com/dop251/goja v0.0.0-20260311135729-065cd970411c github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.10.1 github.com/go-gormigrate/gormigrate/v2 v2.1.2 github.com/go-webauthn/webauthn v0.16.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/csrf v1.7.1 + github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260313013624-04e51e218220 github.com/redis/go-redis/v9 v9.7.0 github.com/smartystreets/goconvey v1.8.1 github.com/stretchr/testify v1.11.1 @@ -34,7 +37,6 @@ require ( github.com/bytedance/sonic v1.13.3 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect @@ -55,6 +57,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.2.1 // indirect @@ -75,11 +78,9 @@ require ( github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260313013624-04e51e218220 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.17 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect diff --git a/go.sum b/go.sum index 77eb6b8..4e781ae 100644 --- a/go.sum +++ b/go.sum @@ -47,6 +47,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk= +github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/elastic/elastic-transport-go/v8 v8.4.0 h1:EKYiH8CHd33BmMna2Bos1rDNMM89+hdgcymI+KzJCGE= github.com/elastic/elastic-transport-go/v8 v8.4.0/go.mod h1:YLHer5cj0csTzNFXoNQ8qhtGY1GTvSqPnKWKaqQE3Hk= github.com/elastic/go-elasticsearch/v8 v8.12.1 h1:QcuFK5LaZS0pSIj/eAEsxmJWmMo7tUs1aVBbzdIgtnE= @@ -88,6 +90,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= @@ -169,8 +173,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/internal/api/router.go b/internal/api/router.go index e44e5d0..7702e9d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -18,11 +18,13 @@ import ( "github.com/scriptscat/scriptlist/internal/controller/report_ctr" "github.com/scriptscat/scriptlist/internal/controller/resource_ctr" "github.com/scriptscat/scriptlist/internal/controller/script_ctr" + "github.com/scriptscat/scriptlist/internal/controller/similarity_ctr" "github.com/scriptscat/scriptlist/internal/controller/statistics_ctr" "github.com/scriptscat/scriptlist/internal/controller/system_ctr" "github.com/scriptscat/scriptlist/internal/controller/user_ctr" "github.com/scriptscat/scriptlist/internal/service/auth_svc" "github.com/scriptscat/scriptlist/internal/service/script_svc" + "github.com/scriptscat/scriptlist/internal/service/similarity_svc" ) // Router 路由表 @@ -229,6 +231,8 @@ func Router(ctx context.Context, root *mux.Router) error { announcementCtr.Latest, ) } + // 相似度检测控制器(管理端 + 半公开证据页共用同一 stateless 实例) + similarityCtr := similarity_ctr.NewSimilarity() // 管理员接口 { adminCtr := admin_ctr.NewAdmin() @@ -272,6 +276,27 @@ func Router(ctx context.Context, root *mux.Router) error { // Migrate Avatar adminCtr.MigrateAvatar, adminCtr.GetMigrateAvatarStatus, + // Similarity & integrity (Phase 3) + similarityCtr.ListPairs, + similarityCtr.GetPairDetail, + similarityCtr.AddPairWhitelist, + similarityCtr.RemovePairWhitelist, + similarityCtr.RemovePairWhitelistByID, + similarityCtr.ListPairWhitelist, + similarityCtr.ListSuspects, + similarityCtr.ListIntegrityReviews, + similarityCtr.GetIntegrityReview, + similarityCtr.ResolveIntegrityReview, + similarityCtr.ListIntegrityWhitelist, + similarityCtr.AddIntegrityWhitelist, + similarityCtr.RemoveIntegrityWhitelist, + // Phase 4: backfill + manual scan + similarityCtr.TriggerBackfill, + similarityCtr.GetBackfillStatus, + similarityCtr.ManualScan, + similarityCtr.RefreshStopFp, + // Fingerprint parse-failure triage + similarityCtr.ListParseFailures, ) } // 审计日志 @@ -290,5 +315,14 @@ func Router(ctx context.Context, root *mux.Router) error { controller.ScriptList, ) } + // 相似度证据页(半公开,需登录 + 自定义访问权限) + { + r.Group("/", + auth_svc.Auth().RequireLogin(true), + similarity_svc.Access().RequireSimilarityPairAccess(), + ).Bind( + similarityCtr.GetEvidencePair, + ) + } return nil } diff --git a/internal/api/script/script.go b/internal/api/script/script.go index d0ed7a1..1b8312f 100644 --- a/internal/api/script/script.go +++ b/internal/api/script/script.go @@ -129,6 +129,9 @@ type UpdateCodeRequest struct { CategoryID int64 `json:"category_id" form:"category_id" binding:"omitempty,numeric" label:"分类ID"` // 分类ID //Public script_entity.Public `form:"public" binding:"required,oneof=1 2" label:"公开类型"` // 公开类型:1 公开 2 半公开 //Unwell script_entity.UnwellContent `form:"unwell" binding:"required,oneof=1 2" label:"不适内容"` + // SkipIntegrity is an internal flag (not bound to HTTP) that bypasses the + // integrity pre-check. Used by SyncOnce for system-initiated code updates. + SkipIntegrity bool `json:"-" form:"-"` } type UpdateCodeResponse struct { diff --git a/internal/api/similarity/similarity.go b/internal/api/similarity/similarity.go new file mode 100644 index 0000000..65168d7 --- /dev/null +++ b/internal/api/similarity/similarity.go @@ -0,0 +1,348 @@ +package similarity + +import ( + "github.com/cago-frame/cago/pkg/utils/httputils" + "github.com/cago-frame/cago/server/mux" +) + +// ==================== Shared item / detail types ==================== + +// ScriptBrief is a lightweight script reference used in list responses. +type ScriptBrief struct { + ID int64 `json:"id"` + Name string `json:"name"` + UserID int64 `json:"user_id"` + Username string `json:"username"` + Public int `json:"public"` // 1=public 2=unlisted 3=private + Createtime int64 `json:"createtime"` + IsDeleted bool `json:"is_deleted"` // 软删除的脚本仍保留相似对证据,前端需加"已删除"标记 +} + +// ScriptFullInfo extends ScriptBrief with the code version metadata needed +// on the pair detail page (not the code body itself — that goes on PairDetail). +type ScriptFullInfo struct { + ScriptBrief + ScriptCodeID int64 `json:"script_code_id"` + Version string `json:"version"` + CodeCreatedAt int64 `json:"code_created_at"` +} + +type TopSource struct { + ScriptID int64 `json:"script_id"` + ScriptName string `json:"script_name"` + Jaccard float64 `json:"jaccard"` + ContributionPct float64 `json:"contribution_pct"` +} + +type MatchSegment struct { + AStart int `json:"a_start"` + AEnd int `json:"a_end"` + BStart int `json:"b_start"` + BEnd int `json:"b_end"` +} + +type SimilarPairItem struct { + ID int64 `json:"id"` + ScriptA ScriptBrief `json:"script_a"` + ScriptB ScriptBrief `json:"script_b"` + Jaccard float64 `json:"jaccard"` + CommonCount int `json:"common_count"` + EarlierSide string `json:"earlier_side"` // "A" | "B" | "same" + Status int `json:"status"` + DetectedAt int64 `json:"detected_at"` + IntegrityScore float64 `json:"integrity_score,omitempty"` // §10.12, 0 if not available +} + +type SuspectScriptItem struct { + Script ScriptBrief `json:"script"` + MaxJaccard float64 `json:"max_jaccard"` + Coverage float64 `json:"coverage"` + TopSources []TopSource `json:"top_sources"` + PairCount int `json:"pair_count"` + DetectedAt int64 `json:"detected_at"` + IntegrityScore float64 `json:"integrity_score,omitempty"` +} + +type AdminActions struct { + CanWhitelist bool `json:"can_whitelist"` +} + +type PairDetail struct { + ID int64 `json:"id"` + ScriptA ScriptFullInfo `json:"script_a"` + ScriptB ScriptFullInfo `json:"script_b"` + Jaccard float64 `json:"jaccard"` + CommonCount int `json:"common_count"` + AFingerprintCnt int `json:"a_fp_count"` + BFingerprintCnt int `json:"b_fp_count"` + EarlierSide string `json:"earlier_side"` + DetectedAt int64 `json:"detected_at"` + CodeA string `json:"code_a"` + CodeB string `json:"code_b"` + MatchSegments []MatchSegment `json:"match_segments"` + // Admin-only fields (omitted on evidence page) + Status int `json:"status,omitempty"` + ReviewNote string `json:"review_note,omitempty"` + AdminActions *AdminActions `json:"admin_actions,omitempty"` +} + +// ==================== Admin: similarity pairs ==================== + +type ListPairsRequest struct { + mux.Meta `path:"/admin/similarity/pairs" method:"GET"` + httputils.PageRequest `form:",inline"` + Status *int `form:"status"` // nil = any, 0/1/2 = filter + MinJaccard *float64 `form:"min_jaccard"` + ScriptID int64 `form:"script_id"` + ExcludeDeleted bool `form:"exclude_deleted"` // true = 过滤掉任一侧脚本已软删除的对 +} + +type ListPairsResponse struct { + httputils.PageResponse[*SimilarPairItem] `json:",inline"` +} + +type ListSuspectsRequest struct { + mux.Meta `path:"/admin/similarity/suspects" method:"GET"` + httputils.PageRequest `form:",inline"` + MinJaccard *float64 `form:"min_jaccard"` + MinCoverage *float64 `form:"min_coverage"` + Status *int `form:"status"` +} + +type ListSuspectsResponse struct { + httputils.PageResponse[*SuspectScriptItem] `json:",inline"` +} + +type GetPairDetailRequest struct { + mux.Meta `path:"/admin/similarity/pairs/:id" method:"GET"` + ID int64 `uri:"id" binding:"required"` +} + +type GetPairDetailResponse struct { + Detail *PairDetail `json:"detail"` +} + +type AddPairWhitelistRequest struct { + mux.Meta `path:"/admin/similarity/pairs/:id/whitelist" method:"POST"` + ID int64 `uri:"id" binding:"required"` + Reason string `json:"reason" binding:"required,max=255"` +} + +type AddPairWhitelistResponse struct{} + +type RemovePairWhitelistRequest struct { + mux.Meta `path:"/admin/similarity/pairs/:id/whitelist" method:"DELETE"` + ID int64 `uri:"id" binding:"required"` +} + +type RemovePairWhitelistResponse struct{} + +type RemovePairWhitelistByIDRequest struct { + mux.Meta `path:"/admin/similarity/whitelist/:id" method:"DELETE"` + ID int64 `uri:"id" binding:"required"` +} + +type RemovePairWhitelistByIDResponse struct{} + +type PairWhitelistItem struct { + ID int64 `json:"id"` + ScriptA ScriptBrief `json:"script_a"` + ScriptB ScriptBrief `json:"script_b"` + Reason string `json:"reason"` + AddedBy int64 `json:"added_by"` + AddedByName string `json:"added_by_name"` + Createtime int64 `json:"createtime"` +} + +type ListPairWhitelistRequest struct { + mux.Meta `path:"/admin/similarity/whitelist" method:"GET"` + httputils.PageRequest `form:",inline"` +} + +type ListPairWhitelistResponse struct { + httputils.PageResponse[*PairWhitelistItem] `json:",inline"` +} + +// ==================== Admin: integrity review queue ==================== + +type IntegritySubScores struct { + CatA float64 `json:"cat_a"` + CatB float64 `json:"cat_b"` + CatC float64 `json:"cat_c"` + CatD float64 `json:"cat_d"` +} + +type IntegrityHitSignal struct { + Name string `json:"name"` + Value float64 `json:"value"` + Threshold float64 `json:"threshold"` +} + +type IntegrityReviewItem struct { + ID int64 `json:"id"` + Script ScriptBrief `json:"script"` + ScriptCodeID int64 `json:"script_code_id"` + Score float64 `json:"score"` + Status int `json:"status"` + Createtime int64 `json:"createtime"` +} + +type IntegrityReviewDetail struct { + IntegrityReviewItem + SubScores IntegritySubScores `json:"sub_scores"` + HitSignals []IntegrityHitSignal `json:"hit_signals"` + Code string `json:"code"` + ReviewedBy int64 `json:"reviewed_by,omitempty"` + ReviewedAt int64 `json:"reviewed_at,omitempty"` + ReviewNote string `json:"review_note,omitempty"` +} + +type ListIntegrityReviewsRequest struct { + mux.Meta `path:"/admin/similarity/integrity/reviews" method:"GET"` + httputils.PageRequest `form:",inline"` + Status *int `form:"status"` // 0=pending 1=ok 2=violated +} + +type ListIntegrityReviewsResponse struct { + httputils.PageResponse[*IntegrityReviewItem] `json:",inline"` +} + +type GetIntegrityReviewRequest struct { + mux.Meta `path:"/admin/similarity/integrity/reviews/:id" method:"GET"` + ID int64 `uri:"id" binding:"required"` +} + +type GetIntegrityReviewResponse struct { + Detail *IntegrityReviewDetail `json:"detail"` +} + +type ResolveIntegrityReviewRequest struct { + mux.Meta `path:"/admin/similarity/integrity/reviews/:id/resolve" method:"POST"` + ID int64 `uri:"id" binding:"required"` + Status int `json:"status" binding:"required,oneof=1 2"` + Note string `json:"note" binding:"max=255"` +} + +type ResolveIntegrityReviewResponse struct{} + +// ==================== Admin: integrity whitelist ==================== + +type IntegrityWhitelistItem struct { + ID int64 `json:"id"` + Script ScriptBrief `json:"script"` + Reason string `json:"reason"` + AddedBy int64 `json:"added_by"` + AddedByName string `json:"added_by_name"` + Createtime int64 `json:"createtime"` +} + +type ListIntegrityWhitelistRequest struct { + mux.Meta `path:"/admin/similarity/integrity/whitelist" method:"GET"` + httputils.PageRequest `form:",inline"` +} + +type ListIntegrityWhitelistResponse struct { + httputils.PageResponse[*IntegrityWhitelistItem] `json:",inline"` +} + +type AddIntegrityWhitelistRequest struct { + mux.Meta `path:"/admin/similarity/integrity/whitelist" method:"POST"` + ScriptID int64 `json:"script_id" binding:"required"` + Reason string `json:"reason" binding:"required,max=255"` +} + +type AddIntegrityWhitelistResponse struct{} + +type RemoveIntegrityWhitelistRequest struct { + mux.Meta `path:"/admin/similarity/integrity/whitelist/:script_id" method:"DELETE"` + ScriptID int64 `uri:"script_id" binding:"required"` +} + +type RemoveIntegrityWhitelistResponse struct{} + +// ==================== Admin: fingerprint parse failures ==================== + +// ParseFailureItem describes one script whose similarity fingerprint row is in +// a non-OK state (failed to parse, or skipped for size / soft-delete). Used for +// the admin triage list so operators can find scripts that are invisible to +// similarity comparison and rescan them. +type ParseFailureItem struct { + ScriptID int64 `json:"script_id"` + Script ScriptBrief `json:"script"` + ParseStatus int `json:"parse_status"` // 1=failed 2=skip + ParseError string `json:"parse_error"` + ScannedAt int64 `json:"scanned_at"` + FingerprintCnt int `json:"fingerprint_cnt"` + Updatetime int64 `json:"updatetime"` +} + +// ListParseFailuresRequest lists fingerprint rows with parse_status != ok. Default status filter is 1 (failed). Pass status=2 to see skipped rows (typically too_large / soft_deleted). +type ListParseFailuresRequest struct { + mux.Meta `path:"/admin/similarity/parse-failures" method:"GET"` + httputils.PageRequest `form:",inline"` + Status *int `form:"status" binding:"omitempty,oneof=1 2"` +} + +type ListParseFailuresResponse struct { + httputils.PageResponse[*ParseFailureItem] `json:",inline"` +} + +// ==================== Semi-public evidence ==================== + +type GetEvidencePairRequest struct { + mux.Meta `path:"/similarity/pair/:id" method:"GET"` + ID int64 `uri:"id" binding:"required"` +} + +type GetEvidencePairResponse struct { + Detail *PairDetail `json:"detail"` +} + +// ==================== Admin: Phase 4 backfill / manual scan ==================== + +// TriggerBackfillRequest starts a full-library rescan from the persisted cursor. +type TriggerBackfillRequest struct { + mux.Meta `path:"/admin/similarity/backfill" method:"POST"` + // Reset=true resets the cursor to 0 AND stamps force=true on every + // published scan message so the consumer bypasses its code_hash + // short-circuit. Without this the walk would be a no-op for every + // script that was previously scanned OK. §8.5 step 9 re-run. + Reset bool `json:"reset"` +} + +type TriggerBackfillResponse struct { + Running bool `json:"running"` + Cursor int64 `json:"cursor"` + Total int64 `json:"total"` + StartedAt int64 `json:"started_at"` + FinishedAt int64 `json:"finished_at"` +} + +type GetBackfillStatusRequest struct { + mux.Meta `path:"/admin/similarity/backfill/status" method:"GET"` +} + +type GetBackfillStatusResponse struct { + Running bool `json:"running"` + Cursor int64 `json:"cursor"` + Total int64 `json:"total"` + StartedAt int64 `json:"started_at"` + FinishedAt int64 `json:"finished_at"` +} + +// ManualScanRequest forces a single-script rescan via NSQ. Fire-and-forget. +type ManualScanRequest struct { + mux.Meta `path:"/admin/similarity/scan/:script_id" method:"POST"` + ScriptID int64 `uri:"script_id" binding:"required"` +} + +type ManualScanResponse struct{} + +// RefreshStopFpRequest synchronously re-runs the stop-fingerprint ES +// aggregation. Used for §8.5 step 8 (post-backfill manual refresh) when the +// admin doesn't want to wait for the hourly cron. +type RefreshStopFpRequest struct { + mux.Meta `path:"/admin/similarity/stop-fp/refresh" method:"POST"` +} + +type RefreshStopFpResponse struct{} diff --git a/internal/controller/similarity_ctr/similarity.go b/internal/controller/similarity_ctr/similarity.go new file mode 100644 index 0000000..3b2392b --- /dev/null +++ b/internal/controller/similarity_ctr/similarity.go @@ -0,0 +1,83 @@ +package similarity_ctr + +import ( + "context" + + api "github.com/scriptscat/scriptlist/internal/api/similarity" + "github.com/scriptscat/scriptlist/internal/service/similarity_svc" +) + +type Similarity struct{} + +func NewSimilarity() *Similarity { return &Similarity{} } + +// Pairs +func (c *Similarity) ListPairs(ctx context.Context, req *api.ListPairsRequest) (*api.ListPairsResponse, error) { + return similarity_svc.Admin().ListPairs(ctx, req) +} +func (c *Similarity) GetPairDetail(ctx context.Context, req *api.GetPairDetailRequest) (*api.GetPairDetailResponse, error) { + return similarity_svc.Admin().GetPairDetail(ctx, req) +} +func (c *Similarity) AddPairWhitelist(ctx context.Context, req *api.AddPairWhitelistRequest) (*api.AddPairWhitelistResponse, error) { + return similarity_svc.Admin().AddPairWhitelist(ctx, req) +} +func (c *Similarity) RemovePairWhitelist(ctx context.Context, req *api.RemovePairWhitelistRequest) (*api.RemovePairWhitelistResponse, error) { + return similarity_svc.Admin().RemovePairWhitelist(ctx, req) +} +func (c *Similarity) RemovePairWhitelistByID(ctx context.Context, req *api.RemovePairWhitelistByIDRequest) (*api.RemovePairWhitelistByIDResponse, error) { + return similarity_svc.Admin().RemovePairWhitelistByID(ctx, req) +} +func (c *Similarity) ListPairWhitelist(ctx context.Context, req *api.ListPairWhitelistRequest) (*api.ListPairWhitelistResponse, error) { + return similarity_svc.Admin().ListPairWhitelist(ctx, req) +} + +// Suspects +func (c *Similarity) ListSuspects(ctx context.Context, req *api.ListSuspectsRequest) (*api.ListSuspectsResponse, error) { + return similarity_svc.Admin().ListSuspects(ctx, req) +} + +// Integrity reviews +func (c *Similarity) ListIntegrityReviews(ctx context.Context, req *api.ListIntegrityReviewsRequest) (*api.ListIntegrityReviewsResponse, error) { + return similarity_svc.Admin().ListIntegrityReviews(ctx, req) +} +func (c *Similarity) GetIntegrityReview(ctx context.Context, req *api.GetIntegrityReviewRequest) (*api.GetIntegrityReviewResponse, error) { + return similarity_svc.Admin().GetIntegrityReview(ctx, req) +} +func (c *Similarity) ResolveIntegrityReview(ctx context.Context, req *api.ResolveIntegrityReviewRequest) (*api.ResolveIntegrityReviewResponse, error) { + return similarity_svc.Admin().ResolveIntegrityReview(ctx, req) +} + +// Integrity whitelist +func (c *Similarity) ListIntegrityWhitelist(ctx context.Context, req *api.ListIntegrityWhitelistRequest) (*api.ListIntegrityWhitelistResponse, error) { + return similarity_svc.Admin().ListIntegrityWhitelist(ctx, req) +} +func (c *Similarity) AddIntegrityWhitelist(ctx context.Context, req *api.AddIntegrityWhitelistRequest) (*api.AddIntegrityWhitelistResponse, error) { + return similarity_svc.Admin().AddIntegrityWhitelist(ctx, req) +} +func (c *Similarity) RemoveIntegrityWhitelist(ctx context.Context, req *api.RemoveIntegrityWhitelistRequest) (*api.RemoveIntegrityWhitelistResponse, error) { + return similarity_svc.Admin().RemoveIntegrityWhitelist(ctx, req) +} + +// Evidence (semi-public) +func (c *Similarity) GetEvidencePair(ctx context.Context, req *api.GetEvidencePairRequest) (*api.GetEvidencePairResponse, error) { + return similarity_svc.Admin().GetEvidencePair(ctx, req) +} + +// Phase 4: backfill / manual scan +func (c *Similarity) TriggerBackfill(ctx context.Context, req *api.TriggerBackfillRequest) (*api.TriggerBackfillResponse, error) { + return similarity_svc.Admin().TriggerBackfill(ctx, req) +} +func (c *Similarity) GetBackfillStatus(ctx context.Context, req *api.GetBackfillStatusRequest) (*api.GetBackfillStatusResponse, error) { + return similarity_svc.Admin().GetBackfillStatus(ctx, req) +} +func (c *Similarity) ManualScan(ctx context.Context, req *api.ManualScanRequest) (*api.ManualScanResponse, error) { + return similarity_svc.Admin().ManualScan(ctx, req) +} +func (c *Similarity) RefreshStopFp(ctx context.Context, req *api.RefreshStopFpRequest) (*api.RefreshStopFpResponse, error) { + return similarity_svc.Admin().RefreshStopFp(ctx, req) +} + +// Fingerprint parse-failure triage +func (c *Similarity) ListParseFailures(ctx context.Context, req *api.ListParseFailuresRequest) (*api.ListParseFailuresResponse, error) { + return similarity_svc.Admin().ListParseFailures(ctx, req) +} diff --git a/internal/model/entity/similarity_entity/fingerprint.go b/internal/model/entity/similarity_entity/fingerprint.go new file mode 100644 index 0000000..d2cb538 --- /dev/null +++ b/internal/model/entity/similarity_entity/fingerprint.go @@ -0,0 +1,30 @@ +package similarity_entity + +type ParseStatus int8 + +const ( + ParseStatusOK ParseStatus = iota // 解析成功 + ParseStatusFailed // 解析失败 + ParseStatusSkip // 跳过(超长/非 JS 等) +) + +// Fingerprint 指纹集合元数据(每脚本一行,本体在 ES) +type Fingerprint struct { + ID int64 `gorm:"column:id;type:bigint(20) unsigned;not null;primary_key;auto_increment"` + ScriptID int64 `gorm:"column:script_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_script"` + UserID int64 `gorm:"column:user_id;type:bigint(20) unsigned;not null;index:idx_user"` + ScriptCodeID int64 `gorm:"column:script_code_id;type:bigint(20) unsigned;not null"` + FingerprintCnt int `gorm:"column:fingerprint_cnt;type:int;not null"` + FingerprintCntEffective int `gorm:"column:fingerprint_cnt_effective;type:int;not null"` + CodeHash string `gorm:"column:code_hash;type:char(64);not null"` + BatchID int64 `gorm:"column:batch_id;type:bigint(20) unsigned;not null"` + ParseStatus ParseStatus `gorm:"column:parse_status;type:tinyint;not null;default:0"` + ParseError string `gorm:"column:parse_error;type:varchar(255)"` + ScannedAt int64 `gorm:"column:scanned_at;type:bigint(20);not null;index:idx_scanned_at"` + Createtime int64 `gorm:"column:createtime;type:bigint(20);not null"` + Updatetime int64 `gorm:"column:updatetime;type:bigint(20);not null"` +} + +func (f *Fingerprint) TableName() string { + return "pre_script_fingerprint" +} diff --git a/internal/model/entity/similarity_entity/integrity_review.go b/internal/model/entity/similarity_entity/integrity_review.go new file mode 100644 index 0000000..3c83f9b --- /dev/null +++ b/internal/model/entity/similarity_entity/integrity_review.go @@ -0,0 +1,30 @@ +package similarity_entity + +type ReviewStatus int8 + +const ( + ReviewStatusPending ReviewStatus = iota // 待审查 + ReviewStatusOK // 审核通过 + ReviewStatusViolated // 认定违规 +) + +// IntegrityReview 完整性警告队列 +type IntegrityReview struct { + ID int64 `gorm:"column:id;type:bigint(20) unsigned;not null;primary_key;auto_increment"` + ScriptID int64 `gorm:"column:script_id;type:bigint(20) unsigned;not null"` + ScriptCodeID int64 `gorm:"column:script_code_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_code"` + UserID int64 `gorm:"column:user_id;type:bigint(20) unsigned;not null"` + Score float64 `gorm:"column:score;type:decimal(5,4);not null;index:idx_status_score,priority:2"` + SubScores string `gorm:"column:sub_scores;type:json;not null"` + HitSignals string `gorm:"column:hit_signals;type:json;not null"` + Status ReviewStatus `gorm:"column:status;type:tinyint;not null;default:0;index:idx_status_score,priority:1"` + ReviewedBy int64 `gorm:"column:reviewed_by;type:bigint(20) unsigned"` + ReviewedAt int64 `gorm:"column:reviewed_at;type:bigint(20)"` + ReviewNote string `gorm:"column:review_note;type:varchar(255)"` + Createtime int64 `gorm:"column:createtime;type:bigint(20);not null;index:idx_createtime"` + Updatetime int64 `gorm:"column:updatetime;type:bigint(20);not null"` +} + +func (r *IntegrityReview) TableName() string { + return "pre_script_integrity_review" +} diff --git a/internal/model/entity/similarity_entity/integrity_whitelist.go b/internal/model/entity/similarity_entity/integrity_whitelist.go new file mode 100644 index 0000000..ead589f --- /dev/null +++ b/internal/model/entity/similarity_entity/integrity_whitelist.go @@ -0,0 +1,14 @@ +package similarity_entity + +// IntegrityWhitelist 完整性脚本级别豁免名单 +type IntegrityWhitelist struct { + ID int64 `gorm:"column:id;type:bigint(20) unsigned;not null;primary_key;auto_increment"` + ScriptID int64 `gorm:"column:script_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_script"` + Reason string `gorm:"column:reason;type:varchar(255);not null"` + AddedBy int64 `gorm:"column:added_by;type:bigint(20) unsigned;not null"` + Createtime int64 `gorm:"column:createtime;type:bigint(20);not null"` +} + +func (w *IntegrityWhitelist) TableName() string { + return "pre_script_integrity_whitelist" +} diff --git a/internal/model/entity/similarity_entity/similar_pair.go b/internal/model/entity/similarity_entity/similar_pair.go new file mode 100644 index 0000000..bd02779 --- /dev/null +++ b/internal/model/entity/similarity_entity/similar_pair.go @@ -0,0 +1,38 @@ +package similarity_entity + +type PairStatus int8 + +const ( + PairStatusPending PairStatus = iota // 待审查 + PairStatusWhitelisted // 已加入白名单 + PairStatusResolved // 已处理 +) + +// SimilarPair 相似对存档(约定 script_a_id < script_b_id) +type SimilarPair struct { + ID int64 `gorm:"column:id;type:bigint(20) unsigned;not null;primary_key;auto_increment"` + ScriptAID int64 `gorm:"column:script_a_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_pair,priority:1;index:idx_script_a"` + ScriptBID int64 `gorm:"column:script_b_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_pair,priority:2;index:idx_script_b"` + UserAID int64 `gorm:"column:user_a_id;type:bigint(20) unsigned;not null"` + UserBID int64 `gorm:"column:user_b_id;type:bigint(20) unsigned;not null"` + AScriptCodeID int64 `gorm:"column:a_script_code_id;type:bigint(20) unsigned;not null"` + BScriptCodeID int64 `gorm:"column:b_script_code_id;type:bigint(20) unsigned;not null"` + ACodeCreatedAt int64 `gorm:"column:a_code_created_at;type:bigint(20);not null"` + BCodeCreatedAt int64 `gorm:"column:b_code_created_at;type:bigint(20);not null"` + Jaccard float64 `gorm:"column:jaccard;type:decimal(5,4);not null;index:idx_jaccard;index:idx_status_jaccard,priority:2"` + CommonCount int `gorm:"column:common_count;type:int;not null"` + AFpCount int `gorm:"column:a_fp_count;type:int;not null"` + BFpCount int `gorm:"column:b_fp_count;type:int;not null"` + MatchedFp []byte `gorm:"column:matched_fp;type:mediumblob;not null"` + DetectedAt int64 `gorm:"column:detected_at;type:bigint(20);not null;index:idx_detected"` + Status PairStatus `gorm:"column:status;type:tinyint;not null;default:0;index:idx_status_jaccard,priority:1"` + ReviewedBy int64 `gorm:"column:reviewed_by;type:bigint(20) unsigned"` + ReviewedAt int64 `gorm:"column:reviewed_at;type:bigint(20)"` + ReviewNote string `gorm:"column:review_note;type:varchar(255)"` + Createtime int64 `gorm:"column:createtime;type:bigint(20);not null"` + Updatetime int64 `gorm:"column:updatetime;type:bigint(20);not null"` +} + +func (p *SimilarPair) TableName() string { + return "pre_script_similar_pair" +} diff --git a/internal/model/entity/similarity_entity/similarity_whitelist.go b/internal/model/entity/similarity_entity/similarity_whitelist.go new file mode 100644 index 0000000..0bb20ae --- /dev/null +++ b/internal/model/entity/similarity_entity/similarity_whitelist.go @@ -0,0 +1,15 @@ +package similarity_entity + +// SimilarityWhitelist 相似对级别白名单 +type SimilarityWhitelist struct { + ID int64 `gorm:"column:id;type:bigint(20) unsigned;not null;primary_key;auto_increment"` + ScriptAID int64 `gorm:"column:script_a_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_pair,priority:1"` + ScriptBID int64 `gorm:"column:script_b_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_pair,priority:2"` + Reason string `gorm:"column:reason;type:varchar(255);not null"` + AddedBy int64 `gorm:"column:added_by;type:bigint(20) unsigned;not null"` + Createtime int64 `gorm:"column:createtime;type:bigint(20);not null"` +} + +func (w *SimilarityWhitelist) TableName() string { + return "pre_script_similarity_whitelist" +} diff --git a/internal/model/entity/similarity_entity/suspect_summary.go b/internal/model/entity/similarity_entity/suspect_summary.go new file mode 100644 index 0000000..c613d9e --- /dev/null +++ b/internal/model/entity/similarity_entity/suspect_summary.go @@ -0,0 +1,35 @@ +package similarity_entity + +type SuspectStatus int8 + +const ( + SuspectStatusPending SuspectStatus = iota // 待审查 + SuspectStatusIgnored // 已忽略 +) + +// SuspectSummary 嫌疑脚本聚合(每脚本一行) +type SuspectSummary struct { + ScriptID int64 `gorm:"column:script_id;type:bigint(20) unsigned;primary_key"` + UserID int64 `gorm:"column:user_id;type:bigint(20) unsigned;not null"` + MaxJaccard float64 `gorm:"column:max_jaccard;type:decimal(5,4);not null;index:idx_max_jaccard"` + Coverage float64 `gorm:"column:coverage;type:decimal(5,4);not null;index:idx_coverage"` + TopSources string `gorm:"column:top_sources;type:json;not null"` // JSON-marshaled []TopSource + PairCount int `gorm:"column:pair_count;type:int;not null"` + DetectedAt int64 `gorm:"column:detected_at;type:bigint(20);not null"` + Status SuspectStatus `gorm:"column:status;type:tinyint;not null;default:0;index:idx_status"` + Createtime int64 `gorm:"column:createtime;type:bigint(20);not null"` + Updatetime int64 `gorm:"column:updatetime;type:bigint(20);not null"` +} + +// TopSource is one entry in SuspectSummary.TopSources. +// Lives on the entity package so both the repo (marshal) and the service (build) can use it. +type TopSource struct { + ScriptID int64 `json:"script_id"` + ScriptName string `json:"script_name"` + Jaccard float64 `json:"jaccard"` + ContributionPct float64 `json:"contribution_pct"` +} + +func (s *SuspectSummary) TableName() string { + return "pre_script_suspect_summary" +} diff --git a/internal/pkg/code/code.go b/internal/pkg/code/code.go index ca031df..7dae81a 100644 --- a/internal/pkg/code/code.go +++ b/internal/pkg/code/code.go @@ -178,3 +178,16 @@ const ( ReportReasonInvalid ReportSelfReport ) + +// similarity +// +// 注意:spec §6.2 原本计划使用 103001-103006,但 resource 错误码已经占用了 +// 103000-103002,为避免冲突,相似度错误码迁移到 114000+(webauthn 之后的下一个空闲段)。 +const ( + SimilarityPairNotFound = iota + 114000 // 114000 + SimilarityScanFailed // 114001 + SimilarityParseError // 114002 + SimilarityBackfillInProgress // 114003 + SimilarityAccessDenied // 114004 + SimilarityIntegrityRejected // 114005 +) diff --git a/internal/pkg/code/zh_cn.go b/internal/pkg/code/zh_cn.go index 7c2fcd2..b41ecde 100644 --- a/internal/pkg/code/zh_cn.go +++ b/internal/pkg/code/zh_cn.go @@ -142,4 +142,12 @@ var zhCN = map[int]string{ AuthWebAuthnNoCredentials: "未注册通行密钥", AuthWebAuthnUserNotFound: "未找到对应用户", AuthWebAuthnCredentialLimitExceeded: "通行密钥数量已达上限", + + // 相似度检测 (Phase 2) + SimilarityPairNotFound: "相似对不存在", + SimilarityScanFailed: "相似度扫描失败,请稍后重试", + SimilarityParseError: "代码解析失败,无法进行相似度检测", + SimilarityBackfillInProgress: "回填任务正在执行中,请稍后再试", + SimilarityAccessDenied: "无权访问该相似对", + SimilarityIntegrityRejected: "%s", } diff --git a/internal/repository/script_repo/mock/script_code.go b/internal/repository/script_repo/mock/script_code.go index 188bf18..1adfa23 100644 --- a/internal/repository/script_repo/mock/script_code.go +++ b/internal/repository/script_repo/mock/script_code.go @@ -115,6 +115,21 @@ func (mr *MockScriptCodeRepoMockRecorder) FindAllLatest(ctx, scriptId, offset, w return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindAllLatest", reflect.TypeOf((*MockScriptCodeRepo)(nil).FindAllLatest), ctx, scriptId, offset, withcode) } +// FindByIDIncludeDeleted mocks base method. +func (m *MockScriptCodeRepo) FindByIDIncludeDeleted(ctx context.Context, id int64) (*script_entity.Code, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindByIDIncludeDeleted", ctx, id) + ret0, _ := ret[0].(*script_entity.Code) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindByIDIncludeDeleted indicates an expected call of FindByIDIncludeDeleted. +func (mr *MockScriptCodeRepoMockRecorder) FindByIDIncludeDeleted(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByIDIncludeDeleted", reflect.TypeOf((*MockScriptCodeRepo)(nil).FindByIDIncludeDeleted), ctx, id) +} + // FindByVersion mocks base method. func (m *MockScriptCodeRepo) FindByVersion(ctx context.Context, scriptId int64, version string, withcode bool) (*script_entity.Code, error) { m.ctrl.T.Helper() diff --git a/internal/repository/script_repo/script_code.go b/internal/repository/script_repo/script_code.go index da256f0..f558a4e 100644 --- a/internal/repository/script_repo/script_code.go +++ b/internal/repository/script_repo/script_code.go @@ -19,6 +19,10 @@ import ( //go:generate mockgen -source=./script_code.go -destination=./mock/script_code.go type ScriptCodeRepo interface { Find(ctx context.Context, id int64) (*entity.Code, error) + // FindByIDIncludeDeleted returns the row even if it has been soft-deleted + // (status = consts.DELETE). Used by similarity evidence pages so that + // comparisons referencing deleted-script code remain viewable. + FindByIDIncludeDeleted(ctx context.Context, id int64) (*entity.Code, error) Create(ctx context.Context, scriptCode *entity.Code) error Update(ctx context.Context, scriptCode *entity.Code) error Delete(ctx context.Context, scriptCode *entity.Code) error @@ -73,6 +77,25 @@ func (u *scriptCodeRepo) Find(ctx context.Context, id int64) (*entity.Code, erro return ret, nil } +// FindByIDIncludeDeleted returns the row even if it has been soft-deleted +// (status = consts.DELETE). Soft-delete in this repo is recorded via the +// Status column on entity.Code, NOT via GORM's DeletedAt mechanism, so a +// plain primary-key lookup already includes deleted rows. This method exists +// as an explicit, documented contract for callers (e.g. similarity evidence +// pages) so that future refactors of Find() which may add a status filter +// do not silently break them. No caching is applied: similarity evidence is +// rare and freshness matters more than performance. +func (u *scriptCodeRepo) FindByIDIncludeDeleted(ctx context.Context, id int64) (*entity.Code, error) { + ret := &entity.Code{ID: id} + if err := db.Ctx(ctx).First(ret).Error; err != nil { + if db.RecordNotFound(err) { + return nil, nil + } + return nil, err + } + return ret, nil +} + func (u *scriptCodeRepo) Create(ctx context.Context, scriptCode *entity.Code) error { if err := db.Ctx(ctx).Create(scriptCode).Error; err != nil { return err diff --git a/internal/repository/similarity_repo/backfill_state.go b/internal/repository/similarity_repo/backfill_state.go new file mode 100644 index 0000000..3bad165 --- /dev/null +++ b/internal/repository/similarity_repo/backfill_state.go @@ -0,0 +1,85 @@ +package similarity_repo + +import ( + "context" + "strconv" + "time" + + "github.com/cago-frame/cago/database/redis" + "github.com/scriptscat/scriptlist/internal/model/entity/system_config_entity" + "github.com/scriptscat/scriptlist/internal/repository/system_config_repo" +) + +const ( + // backfillRunningRedisKey is the Redis SETNX key guarding one-at-a-time + // backfill execution. TTL is large (2h) so a crashed worker can't hold the + // lock forever — a stale lock auto-expires and the next admin click wins. + backfillRunningRedisKey = "similarity:backfill_running" + backfillRunningTTL = 2 * time.Hour +) + +//go:generate mockgen -source=backfill_state.go -destination=mock/backfill_state.go + +// BackfillStateRepo provides low-level data access for the similarity backfill +// state: Redis-based distributed locking and system_config cursor persistence. +type BackfillStateRepo interface { + // AcquireLock attempts to atomically claim the backfill lock via Redis SETNX. + // Returns true if the lock was acquired, false if already held. + AcquireLock(ctx context.Context) (bool, error) + // ReleaseLock releases the backfill Redis lock. + ReleaseLock(ctx context.Context) error + // CheckLock reports whether the backfill Redis lock is currently held. + CheckLock(ctx context.Context) (bool, error) + // ReadInt64Config reads an int64 value from system_config by key. + // Returns 0 (not an error) if the key is missing or has a non-numeric value. + ReadInt64Config(ctx context.Context, key string) (int64, error) + // WriteConfig upserts a string value into system_config by key. + WriteConfig(ctx context.Context, key, value string) error +} + +var defaultBackfillState BackfillStateRepo = &backfillStateRepo{} + +func BackfillState() BackfillStateRepo { return defaultBackfillState } + +func RegisterBackfillState(r BackfillStateRepo) { defaultBackfillState = r } + +type backfillStateRepo struct{} + +func (r *backfillStateRepo) AcquireLock(ctx context.Context) (bool, error) { + ok, err := redis.Ctx(ctx).SetNX(backfillRunningRedisKey, "1", backfillRunningTTL).Result() + return ok, err +} + +func (r *backfillStateRepo) ReleaseLock(ctx context.Context) error { + _, err := redis.Ctx(ctx).Del(backfillRunningRedisKey).Result() + return err +} + +func (r *backfillStateRepo) CheckLock(ctx context.Context) (bool, error) { + n, err := redis.Ctx(ctx).Client.Exists(ctx, backfillRunningRedisKey).Result() + if err != nil { + return false, err + } + return n > 0, nil +} + +func (r *backfillStateRepo) ReadInt64Config(ctx context.Context, key string) (int64, error) { + row, err := system_config_repo.SystemConfig().FindByKey(ctx, key) + if err != nil || row == nil { + return 0, err + } + n, perr := strconv.ParseInt(row.ConfigValue, 10, 64) + if perr != nil { + // A manual edit typo in system_config shouldn't crash the admin page — + // silently coerce to 0 and let the admin notice via the UI. + return 0, nil //nolint:nilerr // graceful malformed-value handling + } + return n, nil +} + +func (r *backfillStateRepo) WriteConfig(ctx context.Context, key, value string) error { + return system_config_repo.SystemConfig().Upsert(ctx, []*system_config_entity.SystemConfig{{ + ConfigKey: key, + ConfigValue: value, + }}) +} diff --git a/internal/repository/similarity_repo/doc.go b/internal/repository/similarity_repo/doc.go new file mode 100644 index 0000000..a0a17a8 --- /dev/null +++ b/internal/repository/similarity_repo/doc.go @@ -0,0 +1,4 @@ +// Package similarity_repo provides MySQL and Elasticsearch persistence for the +// code similarity detection system. See +// docs/superpowers/specs/2026-04-13-code-similarity-detection-design.md. +package similarity_repo diff --git a/internal/repository/similarity_repo/fingerprint.go b/internal/repository/similarity_repo/fingerprint.go new file mode 100644 index 0000000..d8dcf9f --- /dev/null +++ b/internal/repository/similarity_repo/fingerprint.go @@ -0,0 +1,109 @@ +package similarity_repo + +import ( + "context" + + "github.com/cago-frame/cago/database/db" + "github.com/cago-frame/cago/pkg/utils/httputils" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + "gorm.io/gorm/clause" +) + +// ParseFailureFilter selects fingerprint rows with parse_status != ok. If +// Statuses is empty the caller sees both failed (1) and skip (2). +type ParseFailureFilter struct { + Statuses []similarity_entity.ParseStatus +} + +//go:generate mockgen -source=./fingerprint.go -destination=./mock/fingerprint.go + +// FingerprintRepo persists similarity fingerprint metadata (one row per script). +// +// 不使用缓存:每次扫描都会更新本表,缓存会带来过期数据。 +// 时间戳由调用方在传入 entity / 参数时设置,repo 不主动获取当前时间,保持 repo 与时钟解耦。 +// +// NOTE: Upsert 的 DoUpdates 列表必须与 Fingerprint 实体字段保持同步(除 id / script_id / createtime 外)。 +type FingerprintRepo interface { + FindByScriptID(ctx context.Context, scriptID int64) (*similarity_entity.Fingerprint, error) + Upsert(ctx context.Context, fp *similarity_entity.Fingerprint) error + UpdateParseStatus(ctx context.Context, scriptID int64, status similarity_entity.ParseStatus, parseError string, scannedAt int64) error + Delete(ctx context.Context, scriptID int64) error + ListByParseStatus(ctx context.Context, filter ParseFailureFilter, page httputils.PageRequest) ([]*similarity_entity.Fingerprint, int64, error) +} + +var defaultFingerprint FingerprintRepo + +func Fingerprint() FingerprintRepo { return defaultFingerprint } + +func RegisterFingerprint(i FingerprintRepo) { defaultFingerprint = i } + +type fingerprintRepo struct{} + +func NewFingerprintRepo() FingerprintRepo { return &fingerprintRepo{} } + +func (r *fingerprintRepo) FindByScriptID(ctx context.Context, scriptID int64) (*similarity_entity.Fingerprint, error) { + var ret similarity_entity.Fingerprint + err := db.Ctx(ctx).Where("script_id = ?", scriptID).First(&ret).Error + if err != nil { + if db.RecordNotFound(err) { + return nil, nil + } + return nil, err + } + return &ret, nil +} + +func (r *fingerprintRepo) Upsert(ctx context.Context, fp *similarity_entity.Fingerprint) error { + return db.Ctx(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "script_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "user_id", "script_code_id", "fingerprint_cnt", "fingerprint_cnt_effective", + "code_hash", "batch_id", "parse_status", "parse_error", "scanned_at", "updatetime", + }), + }).Create(fp).Error +} + +// UpdateParseStatus marks an existing fingerprint row's parse_status. The +// caller supplies scannedAt so the repo stays clock-free. +func (r *fingerprintRepo) UpdateParseStatus(ctx context.Context, scriptID int64, status similarity_entity.ParseStatus, parseError string, scannedAt int64) error { + return db.Ctx(ctx).Model(&similarity_entity.Fingerprint{}). + Where("script_id = ?", scriptID). + Updates(map[string]any{ + "parse_status": status, + "parse_error": parseError, + "scanned_at": scannedAt, + "updatetime": scannedAt, + }).Error +} + +func (r *fingerprintRepo) Delete(ctx context.Context, scriptID int64) error { + return db.Ctx(ctx).Where("script_id = ?", scriptID).Delete(&similarity_entity.Fingerprint{}).Error +} + +// ListByParseStatus returns fingerprint rows matching the given parse_status +// filter, ordered by scanned_at DESC (freshest failures first). If filter +// Statuses is empty, both failed (1) and skip (2) rows are returned — i.e. +// everything that is not OK. +func (r *fingerprintRepo) ListByParseStatus(ctx context.Context, filter ParseFailureFilter, page httputils.PageRequest) ([]*similarity_entity.Fingerprint, int64, error) { + q := db.Ctx(ctx).Model(&similarity_entity.Fingerprint{}) + if len(filter.Statuses) > 0 { + q = q.Where("parse_status IN ?", filter.Statuses) + } else { + q = q.Where("parse_status IN ?", []similarity_entity.ParseStatus{ + similarity_entity.ParseStatusFailed, + similarity_entity.ParseStatusSkip, + }) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []*similarity_entity.Fingerprint + if err := q.Order("scanned_at DESC"). + Offset(page.GetOffset()). + Limit(page.GetLimit()). + Find(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} diff --git a/internal/repository/similarity_repo/fingerprint_es.go b/internal/repository/similarity_repo/fingerprint_es.go new file mode 100644 index 0000000..30d9da3 --- /dev/null +++ b/internal/repository/similarity_repo/fingerprint_es.go @@ -0,0 +1,458 @@ +package similarity_repo + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/cago-frame/cago/database/elasticsearch" +) + +//go:generate mockgen -source=./fingerprint_es.go -destination=./mock/fingerprint_es.go + +// FingerprintDoc is one ES document (one indexed fingerprint). +type FingerprintDoc struct { + ScriptID int64 `json:"script_id"` + UserID int64 `json:"user_id"` + BatchID int64 `json:"batch_id"` + Fingerprint string `json:"fingerprint"` // hex-encoded uint64 + Position int `json:"position"` +} + +// CandidateHit aggregates ES results: how many fingerprints from the query +// matched per other-script. +type CandidateHit struct { + ScriptID int64 + UserID int64 + CommonCount int +} + +// StopFpEntry is one row of the stop-fp aggregation query. +type StopFpEntry struct { + Fingerprint string + DocCount int +} + +// FingerprintPosition is one (fingerprint, byte offset) pair returned by +// FindPositionsByFingerprints for match-segment reconstruction on the +// evidence/detail page. +type FingerprintPosition struct { + Fingerprint string `json:"fingerprint"` + Position int `json:"position"` +} + +// FingerprintESRepo is the Elasticsearch side of the similarity index. +type FingerprintESRepo interface { + BulkInsert(ctx context.Context, docs []FingerprintDoc) error + DeleteOldBatches(ctx context.Context, scriptID, currentBatchID int64) error + DeleteByScriptID(ctx context.Context, scriptID int64) error + // FindCandidates returns up to `limit` other scripts that share at least + // one fingerprint from `queryFps`, excluding the original script and the + // same user. `stopFps` are excluded via must_not. + FindCandidates(ctx context.Context, scriptID, userID int64, queryFps, stopFps []string, limit int) ([]CandidateHit, error) + // CountDistinctMatched returns the number of distinct fingerprints from + // `queryFps` that also appear in some *other* script (different script_id + // AND different user_id), with `stopFps` excluded. This is the numerator + // of Coverage(X) per spec §4.1 Step 5 — a single ES cardinality aggregation + // instead of summing per-candidate common counts (which double-counts + // fingerprints shared across multiple candidates). + CountDistinctMatched(ctx context.Context, scriptID, userID int64, queryFps, stopFps []string) (int, error) + // AggregateStopFp returns fingerprints whose doc_count > cutoff across the + // whole index (Phase 2 stop-fp refresh job). + AggregateStopFp(ctx context.Context, cutoff int) ([]StopFpEntry, error) + // FindPositionsByFingerprints returns all (fingerprint, position) pairs + // for the given (scriptID, batchID) whose fingerprint is in `fps`. Used by + // the pair detail / evidence page to reconstruct matched code segments. + FindPositionsByFingerprints(ctx context.Context, scriptID, batchID int64, fps []string) ([]FingerprintPosition, error) + // FindAllFingerprintPositions returns every (fingerprint, position) pair + // indexed for a (scriptID, batchID) tuple. Used by BuildMatchSegments on + // the pair detail page where we derive common fingerprints live (since + // Phase 2 does not persist the common-fingerprint list on similar_pair). + FindAllFingerprintPositions(ctx context.Context, scriptID, batchID int64) ([]FingerprintPosition, error) +} + +var defaultFingerprintES FingerprintESRepo + +func FingerprintES() FingerprintESRepo { return defaultFingerprintES } + +func RegisterFingerprintES(i FingerprintESRepo) { defaultFingerprintES = i } + +type fingerprintESRepo struct{} + +func NewFingerprintESRepo() FingerprintESRepo { return &fingerprintESRepo{} } + +func docID(scriptID, batchID int64, seq int) string { + return strconv.FormatInt(scriptID, 10) + "_" + strconv.FormatInt(batchID, 10) + "_" + strconv.Itoa(seq) +} + +func (r *fingerprintESRepo) BulkInsert(ctx context.Context, docs []FingerprintDoc) error { + if len(docs) == 0 { + return nil + } + client := elasticsearch.Ctx(ctx) + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + for _, d := range docs { + meta := map[string]any{ + "index": map[string]any{ + "_index": FingerprintIndexName, + "_id": docID(d.ScriptID, d.BatchID, d.Position), + }, + } + if err := enc.Encode(meta); err != nil { + return err + } + if err := enc.Encode(d); err != nil { + return err + } + } + resp, err := client.Bulk(bytes.NewReader(buf.Bytes()), client.Bulk.WithContext(ctx)) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.IsError() { + return fmt.Errorf("es bulk failed: %s", resp.String()) + } + return nil +} + +func (r *fingerprintESRepo) DeleteOldBatches(ctx context.Context, scriptID, currentBatchID int64) error { + body := fmt.Sprintf(`{ + "query": { "bool": { "must": [ + { "term": { "script_id": %d } }, + { "range": { "batch_id": { "lt": %d } } } + ] } } +}`, scriptID, currentBatchID) + client := elasticsearch.Ctx(ctx) + resp, err := client.DeleteByQuery( + []string{FingerprintIndexName}, + bytes.NewReader([]byte(body)), + client.DeleteByQuery.WithContext(ctx), + ) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.IsError() { + return fmt.Errorf("es delete_by_query failed: %s", resp.String()) + } + return nil +} + +func (r *fingerprintESRepo) DeleteByScriptID(ctx context.Context, scriptID int64) error { + body := fmt.Sprintf(`{ "query": { "term": { "script_id": %d } } }`, scriptID) + client := elasticsearch.Ctx(ctx) + resp, err := client.DeleteByQuery( + []string{FingerprintIndexName}, + bytes.NewReader([]byte(body)), + client.DeleteByQuery.WithContext(ctx), + ) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.IsError() { + return fmt.Errorf("es delete_by_query failed: %s", resp.String()) + } + return nil +} + +// buildFindCandidatesBody constructs the ES _search body for FindCandidates. +// It is extracted as a pure function so the body construction can be tested +// without mocking Elasticsearch. +func buildFindCandidatesBody(scriptID, userID int64, queryFps, stopFps []string, limit int) ([]byte, error) { + mustNot := []any{ + map[string]any{"term": map[string]any{"script_id": scriptID}}, + map[string]any{"term": map[string]any{"user_id": userID}}, + } + if len(stopFps) > 0 { + mustNot = append(mustNot, map[string]any{"terms": map[string]any{"fingerprint": stopFps}}) + } + body := map[string]any{ + "size": 0, + "query": map[string]any{ + "bool": map[string]any{ + "must": []any{ + map[string]any{"terms": map[string]any{"fingerprint": queryFps}}, + }, + "must_not": mustNot, + }, + }, + "aggs": map[string]any{ + "by_script": map[string]any{ + "terms": map[string]any{"field": "script_id", "size": limit}, + "aggs": map[string]any{ + "author": map[string]any{"terms": map[string]any{"field": "user_id", "size": 1}}, + }, + }, + }, + } + return json.Marshal(body) +} + +func (r *fingerprintESRepo) FindCandidates(ctx context.Context, scriptID, userID int64, queryFps, stopFps []string, limit int) ([]CandidateHit, error) { + if len(queryFps) == 0 || limit <= 0 { + return nil, nil + } + bodyBytes, err := buildFindCandidatesBody(scriptID, userID, queryFps, stopFps, limit) + if err != nil { + return nil, err + } + client := elasticsearch.Ctx(ctx) + resp, err := client.Search( + client.Search.WithIndex(FingerprintIndexName), + client.Search.WithBody(bytes.NewReader(bodyBytes)), + client.Search.WithContext(ctx), + ) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.IsError() { + return nil, fmt.Errorf("es search failed: %s", resp.String()) + } + var parsed struct { + Aggregations struct { + ByScript struct { + Buckets []struct { + Key int64 `json:"key"` + DocCount int `json:"doc_count"` + Author struct { + Buckets []struct { + Key int64 `json:"key"` + } `json:"buckets"` + } `json:"author"` + } `json:"buckets"` + } `json:"by_script"` + } `json:"aggregations"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, err + } + out := make([]CandidateHit, 0, len(parsed.Aggregations.ByScript.Buckets)) + for _, b := range parsed.Aggregations.ByScript.Buckets { + hit := CandidateHit{ScriptID: b.Key, CommonCount: b.DocCount} + if len(b.Author.Buckets) > 0 { + hit.UserID = b.Author.Buckets[0].Key + } + out = append(out, hit) + } + return out, nil +} + +// buildCountDistinctMatchedBody constructs the ES _search body for +// CountDistinctMatched. Pure function for testability. +func buildCountDistinctMatchedBody(scriptID, userID int64, queryFps, stopFps []string) ([]byte, error) { + mustNot := []any{ + map[string]any{"term": map[string]any{"script_id": scriptID}}, + map[string]any{"term": map[string]any{"user_id": userID}}, + } + if len(stopFps) > 0 { + mustNot = append(mustNot, map[string]any{"terms": map[string]any{"fingerprint": stopFps}}) + } + body := map[string]any{ + "size": 0, + "query": map[string]any{ + "bool": map[string]any{ + "must": []any{ + map[string]any{"terms": map[string]any{"fingerprint": queryFps}}, + }, + "must_not": mustNot, + }, + }, + "aggs": map[string]any{ + "distinct_fp": map[string]any{ + "cardinality": map[string]any{ + "field": "fingerprint", + "precision_threshold": 40000, + }, + }, + }, + } + return json.Marshal(body) +} + +func (r *fingerprintESRepo) CountDistinctMatched(ctx context.Context, scriptID, userID int64, queryFps, stopFps []string) (int, error) { + if len(queryFps) == 0 { + return 0, nil + } + bodyBytes, err := buildCountDistinctMatchedBody(scriptID, userID, queryFps, stopFps) + if err != nil { + return 0, err + } + client := elasticsearch.Ctx(ctx) + resp, err := client.Search( + client.Search.WithIndex(FingerprintIndexName), + client.Search.WithBody(bytes.NewReader(bodyBytes)), + client.Search.WithContext(ctx), + ) + if err != nil { + return 0, err + } + defer func() { _ = resp.Body.Close() }() + if resp.IsError() { + return 0, fmt.Errorf("es cardinality search failed: %s", resp.String()) + } + var parsed struct { + Aggregations struct { + DistinctFp struct { + Value int `json:"value"` + } `json:"distinct_fp"` + } `json:"aggregations"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return 0, err + } + return parsed.Aggregations.DistinctFp.Value, nil +} + +func (r *fingerprintESRepo) AggregateStopFp(ctx context.Context, cutoff int) ([]StopFpEntry, error) { + body := map[string]any{ + "size": 0, + "aggs": map[string]any{ + "hot_fps": map[string]any{ + "terms": map[string]any{ + "field": "fingerprint", + "size": 10000, + "min_doc_count": cutoff, + }, + }, + }, + } + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, err + } + client := elasticsearch.Ctx(ctx) + resp, err := client.Search( + client.Search.WithIndex(FingerprintIndexName), + client.Search.WithBody(bytes.NewReader(bodyBytes)), + client.Search.WithContext(ctx), + ) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.IsError() { + return nil, fmt.Errorf("es agg failed: %s", resp.String()) + } + var parsed struct { + Aggregations struct { + HotFps struct { + Buckets []struct { + Key string `json:"key"` + DocCount int `json:"doc_count"` + } `json:"buckets"` + } `json:"hot_fps"` + } `json:"aggregations"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, err + } + out := make([]StopFpEntry, 0, len(parsed.Aggregations.HotFps.Buckets)) + for _, b := range parsed.Aggregations.HotFps.Buckets { + out = append(out, StopFpEntry{Fingerprint: b.Key, DocCount: b.DocCount}) + } + return out, nil +} + +func (r *fingerprintESRepo) FindPositionsByFingerprints(ctx context.Context, scriptID, batchID int64, fps []string) ([]FingerprintPosition, error) { + if len(fps) == 0 { + return nil, nil + } + client := elasticsearch.Ctx(ctx) + body := map[string]any{ + "size": 10000, + "_source": []string{"fingerprint", "position"}, + "query": map[string]any{ + "bool": map[string]any{ + "filter": []map[string]any{ + {"term": map[string]any{"script_id": scriptID}}, + {"term": map[string]any{"batch_id": batchID}}, + {"terms": map[string]any{"fingerprint": fps}}, + }, + }, + }, + } + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + resp, err := client.Search( + client.Search.WithContext(ctx), + client.Search.WithIndex(FingerprintIndexName), + client.Search.WithBody(bytes.NewReader(buf)), + ) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.IsError() { + return nil, fmt.Errorf("es position query failed: %s", resp.String()) + } + var parsed struct { + Hits struct { + Hits []struct { + Source FingerprintPosition `json:"_source"` + } `json:"hits"` + } `json:"hits"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, err + } + out := make([]FingerprintPosition, 0, len(parsed.Hits.Hits)) + for _, h := range parsed.Hits.Hits { + out = append(out, h.Source) + } + return out, nil +} + +func (r *fingerprintESRepo) FindAllFingerprintPositions(ctx context.Context, scriptID, batchID int64) ([]FingerprintPosition, error) { + client := elasticsearch.Ctx(ctx) + body := map[string]any{ + "size": 10000, + "_source": []string{"fingerprint", "position"}, + "query": map[string]any{ + "bool": map[string]any{ + "filter": []map[string]any{ + {"term": map[string]any{"script_id": scriptID}}, + {"term": map[string]any{"batch_id": batchID}}, + }, + }, + }, + } + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + resp, err := client.Search( + client.Search.WithContext(ctx), + client.Search.WithIndex(FingerprintIndexName), + client.Search.WithBody(bytes.NewReader(buf)), + ) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.IsError() { + return nil, fmt.Errorf("es all-positions query failed: %s", resp.String()) + } + var parsed struct { + Hits struct { + Hits []struct { + Source FingerprintPosition `json:"_source"` + } `json:"hits"` + } `json:"hits"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, err + } + out := make([]FingerprintPosition, 0, len(parsed.Hits.Hits)) + for _, h := range parsed.Hits.Hits { + out = append(out, h.Source) + } + return out, nil +} diff --git a/internal/repository/similarity_repo/fingerprint_es_init.go b/internal/repository/similarity_repo/fingerprint_es_init.go new file mode 100644 index 0000000..ac4d6fe --- /dev/null +++ b/internal/repository/similarity_repo/fingerprint_es_init.go @@ -0,0 +1,67 @@ +package similarity_repo + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/cago-frame/cago/database/elasticsearch" + "github.com/cago-frame/cago/pkg/logger" + "go.uber.org/zap" +) + +// FingerprintIndexName is the ES index for similarity fingerprints (spec §3.2). +const FingerprintIndexName = "scriptlist_similarity_fp" + +const fingerprintIndexBody = `{ + "settings": { "number_of_shards": 1, "number_of_replicas": 1 }, + "mappings": { + "properties": { + "script_id": { "type": "long" }, + "user_id": { "type": "long" }, + "batch_id": { "type": "long" }, + "fingerprint": { "type": "keyword" }, + "position": { "type": "integer" } + } + } +}` + +// EnsureFingerprintIndex idempotently creates the fingerprint index. Safe to +// call at startup repeatedly: a 400 "resource_already_exists_exception" from +// ES is treated as success. Any other non-2xx response returns an error with +// the response body included. +func EnsureFingerprintIndex(ctx context.Context) error { + client := elasticsearch.Ctx(ctx) + resp, err := client.Indices.Create( + FingerprintIndexName, + client.Indices.Create.WithBody(strings.NewReader(fingerprintIndexBody)), + client.Indices.Create.WithContext(ctx), + ) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + logger.Ctx(ctx).Info("similarity fingerprint index created", zap.String("index", FingerprintIndexName)) + return nil + } + bodyBytes, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("ensure fingerprint index failed: status=%d read body: %w", resp.StatusCode, readErr) + } + if resp.StatusCode == http.StatusBadRequest { + var parsed struct { + Error struct { + Type string `json:"type"` + } `json:"error"` + } + if err := json.Unmarshal(bodyBytes, &parsed); err == nil && parsed.Error.Type == "resource_already_exists_exception" { + logger.Ctx(ctx).Info("similarity fingerprint index already exists", zap.String("index", FingerprintIndexName)) + return nil + } + } + return fmt.Errorf("ensure fingerprint index failed: status=%d body=%s", resp.StatusCode, string(bodyBytes)) +} diff --git a/internal/repository/similarity_repo/fingerprint_es_test.go b/internal/repository/similarity_repo/fingerprint_es_test.go new file mode 100644 index 0000000..ae59255 --- /dev/null +++ b/internal/repository/similarity_repo/fingerprint_es_test.go @@ -0,0 +1,109 @@ +package similarity_repo + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFingerprintIndexName(t *testing.T) { + assert.Equal(t, "scriptlist_similarity_fp", FingerprintIndexName) +} + +func TestFingerprintESRepo_InterfaceShape(t *testing.T) { + var _ FingerprintESRepo = (*fingerprintESRepo)(nil) + assert.NotNil(t, NewFingerprintESRepo()) +} + +// mustNotHasFingerprintTerms reports whether the must_not clause of the parsed +// query body contains a `terms.fingerprint` sub-clause. +func mustNotHasFingerprintTerms(t *testing.T, parsed map[string]any) bool { + t.Helper() + query, ok := parsed["query"].(map[string]any) + require.True(t, ok, "query is not a map") + boolQ, ok := query["bool"].(map[string]any) + require.True(t, ok, "query.bool is not a map") + mustNot, ok := boolQ["must_not"].([]any) + require.True(t, ok, "query.bool.must_not is not a slice") + for _, clause := range mustNot { + cm, ok := clause.(map[string]any) + if !ok { + continue + } + termsClause, ok := cm["terms"].(map[string]any) + if !ok { + continue + } + if _, has := termsClause["fingerprint"]; has { + return true + } + } + return false +} + +func TestBuildFindCandidatesBody(t *testing.T) { + t.Run("with stop fingerprints includes terms fingerprint in must_not", func(t *testing.T) { + bodyBytes, err := buildFindCandidatesBody(42, 7, []string{"abc", "def"}, []string{"stop1", "stop2"}, 50) + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal(bodyBytes, &parsed)) + assert.True(t, mustNotHasFingerprintTerms(t, parsed), "expected terms.fingerprint clause in must_not") + }) + + t.Run("without stop fingerprints omits terms fingerprint in must_not", func(t *testing.T) { + bodyBytes, err := buildFindCandidatesBody(42, 7, []string{"abc"}, nil, 50) + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal(bodyBytes, &parsed)) + assert.False(t, mustNotHasFingerprintTerms(t, parsed), "must_not should not contain terms.fingerprint when stopFps is nil") + // And an empty stopFps slice should behave identically. + bodyBytes2, err := buildFindCandidatesBody(42, 7, []string{"abc"}, []string{}, 50) + require.NoError(t, err) + var parsed2 map[string]any + require.NoError(t, json.Unmarshal(bodyBytes2, &parsed2)) + assert.False(t, mustNotHasFingerprintTerms(t, parsed2), "must_not should not contain terms.fingerprint when stopFps is empty") + }) + + t.Run("script_id user_id and aggregation size match inputs", func(t *testing.T) { + bodyBytes, err := buildFindCandidatesBody(42, 7, []string{"abc"}, nil, 25) + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal(bodyBytes, &parsed)) + + // Walk must_not and verify script_id / user_id term clauses. + query := parsed["query"].(map[string]any) + boolQ := query["bool"].(map[string]any) + mustNot := boolQ["must_not"].([]any) + var sawScript, sawUser bool + for _, clause := range mustNot { + cm, ok := clause.(map[string]any) + if !ok { + continue + } + termClause, ok := cm["term"].(map[string]any) + if !ok { + continue + } + if v, has := termClause["script_id"]; has { + // JSON numbers decode to float64. + assert.EqualValues(t, 42, v) + sawScript = true + } + if v, has := termClause["user_id"]; has { + assert.EqualValues(t, 7, v) + sawUser = true + } + } + assert.True(t, sawScript, "must_not should contain script_id term") + assert.True(t, sawUser, "must_not should contain user_id term") + + // Verify aggregation size. + aggs := parsed["aggs"].(map[string]any) + byScript := aggs["by_script"].(map[string]any) + terms := byScript["terms"].(map[string]any) + assert.EqualValues(t, 25, terms["size"]) + assert.Equal(t, "script_id", terms["field"]) + }) +} diff --git a/internal/repository/similarity_repo/fingerprint_test.go b/internal/repository/similarity_repo/fingerprint_test.go new file mode 100644 index 0000000..6a95e2a --- /dev/null +++ b/internal/repository/similarity_repo/fingerprint_test.go @@ -0,0 +1,13 @@ +package similarity_repo + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFingerprintRepo_InterfaceShape(t *testing.T) { + var _ FingerprintRepo = (*fingerprintRepo)(nil) + r := NewFingerprintRepo() + assert.NotNil(t, r) +} diff --git a/internal/repository/similarity_repo/integrity_review.go b/internal/repository/similarity_repo/integrity_review.go new file mode 100644 index 0000000..3ac737e --- /dev/null +++ b/internal/repository/similarity_repo/integrity_review.go @@ -0,0 +1,105 @@ +package similarity_repo + +import ( + "context" + + "github.com/cago-frame/cago/database/db" + "github.com/cago-frame/cago/pkg/utils/httputils" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + "gorm.io/gorm/clause" +) + +//go:generate mockgen -source=./integrity_review.go -destination=./mock/integrity_review.go + +// IntegrityReviewFilter filters list queries on the integrity_review table. +type IntegrityReviewFilter struct { + Status *int8 +} + +// IntegrityReviewRepo persists integrity-warning queue rows (one per script_code_id). +// +// 不使用缓存:写入由 NSQ consumer 异步触发,读取由管理员面板直接走 SQL 分页。 +// 时间戳由 service / consumer 层设置,repo 不主动获取当前时间,保持 repo 与时钟解耦。 +// +// NOTE: Upsert 的 DoUpdates 列表故意不包含 status / reviewed_by / reviewed_at / review_note — +// 这些字段由管理员审查时的独立写入路径维护,自动扫描覆盖入队不应该清掉人工审查状态。 +type IntegrityReviewRepo interface { + FindByCodeID(ctx context.Context, scriptCodeID int64) (*similarity_entity.IntegrityReview, error) + FindByID(ctx context.Context, id int64) (*similarity_entity.IntegrityReview, error) + List(ctx context.Context, filter IntegrityReviewFilter, page httputils.PageRequest) ([]*similarity_entity.IntegrityReview, int64, error) + Upsert(ctx context.Context, r *similarity_entity.IntegrityReview) error + Resolve(ctx context.Context, id int64, status int8, reviewedBy, reviewedAt int64, note string) error +} + +var defaultIntegrityReview IntegrityReviewRepo + +func IntegrityReview() IntegrityReviewRepo { return defaultIntegrityReview } + +func RegisterIntegrityReview(i IntegrityReviewRepo) { defaultIntegrityReview = i } + +type integrityReviewRepo struct{} + +func NewIntegrityReviewRepo() IntegrityReviewRepo { return &integrityReviewRepo{} } + +func (r *integrityReviewRepo) FindByCodeID(ctx context.Context, codeID int64) (*similarity_entity.IntegrityReview, error) { + var ret similarity_entity.IntegrityReview + err := db.Ctx(ctx).Where("script_code_id = ?", codeID).First(&ret).Error + if err != nil { + if db.RecordNotFound(err) { + return nil, nil + } + return nil, err + } + return &ret, nil +} + +func (r *integrityReviewRepo) FindByID(ctx context.Context, id int64) (*similarity_entity.IntegrityReview, error) { + var ret similarity_entity.IntegrityReview + err := db.Ctx(ctx).Where("id = ?", id).First(&ret).Error + if err != nil { + if db.RecordNotFound(err) { + return nil, nil + } + return nil, err + } + return &ret, nil +} + +func (r *integrityReviewRepo) List(ctx context.Context, filter IntegrityReviewFilter, page httputils.PageRequest) ([]*similarity_entity.IntegrityReview, int64, error) { + q := db.Ctx(ctx).Model(&similarity_entity.IntegrityReview{}) + if filter.Status != nil { + q = q.Where("status = ?", *filter.Status) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []*similarity_entity.IntegrityReview + if err := q.Order("createtime DESC, id DESC"). + Offset(page.GetOffset()).Limit(page.GetLimit()). + Find(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + +func (r *integrityReviewRepo) Upsert(ctx context.Context, row *similarity_entity.IntegrityReview) error { + return db.Ctx(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "script_code_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "script_id", "user_id", "score", "sub_scores", "hit_signals", "updatetime", + }), + }).Create(row).Error +} + +func (r *integrityReviewRepo) Resolve(ctx context.Context, id int64, status int8, reviewedBy, reviewedAt int64, note string) error { + return db.Ctx(ctx).Model(&similarity_entity.IntegrityReview{}). + Where("id = ?", id). + Updates(map[string]any{ + "status": status, + "reviewed_by": reviewedBy, + "reviewed_at": reviewedAt, + "review_note": note, + "updatetime": reviewedAt, + }).Error +} diff --git a/internal/repository/similarity_repo/integrity_review_test.go b/internal/repository/similarity_repo/integrity_review_test.go new file mode 100644 index 0000000..602d09e --- /dev/null +++ b/internal/repository/similarity_repo/integrity_review_test.go @@ -0,0 +1,12 @@ +package similarity_repo + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIntegrityReviewRepo_InterfaceShape(t *testing.T) { + var _ IntegrityReviewRepo = (*integrityReviewRepo)(nil) + assert.NotNil(t, NewIntegrityReviewRepo()) +} diff --git a/internal/repository/similarity_repo/integrity_whitelist.go b/internal/repository/similarity_repo/integrity_whitelist.go new file mode 100644 index 0000000..454ba7a --- /dev/null +++ b/internal/repository/similarity_repo/integrity_whitelist.go @@ -0,0 +1,80 @@ +package similarity_repo + +import ( + "context" + + "github.com/cago-frame/cago/database/db" + "github.com/cago-frame/cago/pkg/utils/httputils" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" +) + +//go:generate mockgen -source=./integrity_whitelist.go -destination=./mock/integrity_whitelist.go + +// IntegrityWhitelistRepo handles per-script integrity-check exemptions. +// +// 不使用缓存:写入低频,读取在 script_svc.UpdateCode 热路径上调用一次, +// 量级很小且 MySQL 单行查询延迟可忽略。 +// 时间戳由 service 层设置,repo 不主动获取当前时间,保持 repo 与时钟解耦。 +type IntegrityWhitelistRepo interface { + IsWhitelisted(ctx context.Context, scriptID int64) (bool, error) + FindByScriptID(ctx context.Context, scriptID int64) (*similarity_entity.IntegrityWhitelist, error) + List(ctx context.Context, page httputils.PageRequest) ([]*similarity_entity.IntegrityWhitelist, int64, error) + Add(ctx context.Context, w *similarity_entity.IntegrityWhitelist) error + Remove(ctx context.Context, scriptID int64) error +} + +var defaultIntegrityWhitelist IntegrityWhitelistRepo + +func IntegrityWhitelist() IntegrityWhitelistRepo { return defaultIntegrityWhitelist } + +func RegisterIntegrityWhitelist(i IntegrityWhitelistRepo) { defaultIntegrityWhitelist = i } + +type integrityWhitelistRepo struct{} + +func NewIntegrityWhitelistRepo() IntegrityWhitelistRepo { return &integrityWhitelistRepo{} } + +func (r *integrityWhitelistRepo) IsWhitelisted(ctx context.Context, scriptID int64) (bool, error) { + var row similarity_entity.IntegrityWhitelist + err := db.Ctx(ctx).Where("script_id = ?", scriptID).First(&row).Error + if err != nil { + if db.RecordNotFound(err) { + return false, nil + } + return false, err + } + return true, nil +} + +func (r *integrityWhitelistRepo) Add(ctx context.Context, w *similarity_entity.IntegrityWhitelist) error { + return db.Ctx(ctx).Create(w).Error +} + +func (r *integrityWhitelistRepo) FindByScriptID(ctx context.Context, scriptID int64) (*similarity_entity.IntegrityWhitelist, error) { + var ret similarity_entity.IntegrityWhitelist + err := db.Ctx(ctx).Where("script_id = ?", scriptID).First(&ret).Error + if err != nil { + if db.RecordNotFound(err) { + return nil, nil + } + return nil, err + } + return &ret, nil +} + +func (r *integrityWhitelistRepo) List(ctx context.Context, page httputils.PageRequest) ([]*similarity_entity.IntegrityWhitelist, int64, error) { + var total int64 + if err := db.Ctx(ctx).Model(&similarity_entity.IntegrityWhitelist{}).Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []*similarity_entity.IntegrityWhitelist + if err := db.Ctx(ctx).Order("id DESC"). + Offset(page.GetOffset()).Limit(page.GetLimit()). + Find(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + +func (r *integrityWhitelistRepo) Remove(ctx context.Context, scriptID int64) error { + return db.Ctx(ctx).Where("script_id = ?", scriptID).Delete(&similarity_entity.IntegrityWhitelist{}).Error +} diff --git a/internal/repository/similarity_repo/integrity_whitelist_test.go b/internal/repository/similarity_repo/integrity_whitelist_test.go new file mode 100644 index 0000000..23496df --- /dev/null +++ b/internal/repository/similarity_repo/integrity_whitelist_test.go @@ -0,0 +1,12 @@ +package similarity_repo + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIntegrityWhitelistRepo_InterfaceShape(t *testing.T) { + var _ IntegrityWhitelistRepo = (*integrityWhitelistRepo)(nil) + assert.NotNil(t, NewIntegrityWhitelistRepo()) +} diff --git a/internal/repository/similarity_repo/mock/backfill_state.go b/internal/repository/similarity_repo/mock/backfill_state.go new file mode 100644 index 0000000..1c21477 --- /dev/null +++ b/internal/repository/similarity_repo/mock/backfill_state.go @@ -0,0 +1,114 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: backfill_state.go +// +// Generated by this command: +// +// mockgen -source=backfill_state.go -destination=mock/backfill_state.go +// + +// Package mock_similarity_repo is a generated GoMock package. +package mock_similarity_repo + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockBackfillStateRepo is a mock of BackfillStateRepo interface. +type MockBackfillStateRepo struct { + ctrl *gomock.Controller + recorder *MockBackfillStateRepoMockRecorder + isgomock struct{} +} + +// MockBackfillStateRepoMockRecorder is the mock recorder for MockBackfillStateRepo. +type MockBackfillStateRepoMockRecorder struct { + mock *MockBackfillStateRepo +} + +// NewMockBackfillStateRepo creates a new mock instance. +func NewMockBackfillStateRepo(ctrl *gomock.Controller) *MockBackfillStateRepo { + mock := &MockBackfillStateRepo{ctrl: ctrl} + mock.recorder = &MockBackfillStateRepoMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockBackfillStateRepo) EXPECT() *MockBackfillStateRepoMockRecorder { + return m.recorder +} + +// AcquireLock mocks base method. +func (m *MockBackfillStateRepo) AcquireLock(ctx context.Context) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AcquireLock", ctx) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AcquireLock indicates an expected call of AcquireLock. +func (mr *MockBackfillStateRepoMockRecorder) AcquireLock(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AcquireLock", reflect.TypeOf((*MockBackfillStateRepo)(nil).AcquireLock), ctx) +} + +// CheckLock mocks base method. +func (m *MockBackfillStateRepo) CheckLock(ctx context.Context) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CheckLock", ctx) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CheckLock indicates an expected call of CheckLock. +func (mr *MockBackfillStateRepoMockRecorder) CheckLock(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckLock", reflect.TypeOf((*MockBackfillStateRepo)(nil).CheckLock), ctx) +} + +// ReadInt64Config mocks base method. +func (m *MockBackfillStateRepo) ReadInt64Config(ctx context.Context, key string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadInt64Config", ctx, key) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadInt64Config indicates an expected call of ReadInt64Config. +func (mr *MockBackfillStateRepoMockRecorder) ReadInt64Config(ctx, key any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadInt64Config", reflect.TypeOf((*MockBackfillStateRepo)(nil).ReadInt64Config), ctx, key) +} + +// ReleaseLock mocks base method. +func (m *MockBackfillStateRepo) ReleaseLock(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReleaseLock", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// ReleaseLock indicates an expected call of ReleaseLock. +func (mr *MockBackfillStateRepoMockRecorder) ReleaseLock(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReleaseLock", reflect.TypeOf((*MockBackfillStateRepo)(nil).ReleaseLock), ctx) +} + +// WriteConfig mocks base method. +func (m *MockBackfillStateRepo) WriteConfig(ctx context.Context, key, value string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WriteConfig", ctx, key, value) + ret0, _ := ret[0].(error) + return ret0 +} + +// WriteConfig indicates an expected call of WriteConfig. +func (mr *MockBackfillStateRepoMockRecorder) WriteConfig(ctx, key, value any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WriteConfig", reflect.TypeOf((*MockBackfillStateRepo)(nil).WriteConfig), ctx, key, value) +} diff --git a/internal/repository/similarity_repo/mock/fingerprint.go b/internal/repository/similarity_repo/mock/fingerprint.go new file mode 100644 index 0000000..9838da3 --- /dev/null +++ b/internal/repository/similarity_repo/mock/fingerprint.go @@ -0,0 +1,117 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./fingerprint.go +// +// Generated by this command: +// +// mockgen -source=./fingerprint.go -destination=./mock/fingerprint.go +// + +// Package mock_similarity_repo is a generated GoMock package. +package mock_similarity_repo + +import ( + context "context" + reflect "reflect" + + httputils "github.com/cago-frame/cago/pkg/utils/httputils" + similarity_entity "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + similarity_repo "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + gomock "go.uber.org/mock/gomock" +) + +// MockFingerprintRepo is a mock of FingerprintRepo interface. +type MockFingerprintRepo struct { + ctrl *gomock.Controller + recorder *MockFingerprintRepoMockRecorder + isgomock struct{} +} + +// MockFingerprintRepoMockRecorder is the mock recorder for MockFingerprintRepo. +type MockFingerprintRepoMockRecorder struct { + mock *MockFingerprintRepo +} + +// NewMockFingerprintRepo creates a new mock instance. +func NewMockFingerprintRepo(ctrl *gomock.Controller) *MockFingerprintRepo { + mock := &MockFingerprintRepo{ctrl: ctrl} + mock.recorder = &MockFingerprintRepoMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFingerprintRepo) EXPECT() *MockFingerprintRepoMockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockFingerprintRepo) Delete(ctx context.Context, scriptID int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, scriptID) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockFingerprintRepoMockRecorder) Delete(ctx, scriptID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockFingerprintRepo)(nil).Delete), ctx, scriptID) +} + +// FindByScriptID mocks base method. +func (m *MockFingerprintRepo) FindByScriptID(ctx context.Context, scriptID int64) (*similarity_entity.Fingerprint, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindByScriptID", ctx, scriptID) + ret0, _ := ret[0].(*similarity_entity.Fingerprint) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindByScriptID indicates an expected call of FindByScriptID. +func (mr *MockFingerprintRepoMockRecorder) FindByScriptID(ctx, scriptID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByScriptID", reflect.TypeOf((*MockFingerprintRepo)(nil).FindByScriptID), ctx, scriptID) +} + +// ListByParseStatus mocks base method. +func (m *MockFingerprintRepo) ListByParseStatus(ctx context.Context, filter similarity_repo.ParseFailureFilter, page httputils.PageRequest) ([]*similarity_entity.Fingerprint, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListByParseStatus", ctx, filter, page) + ret0, _ := ret[0].([]*similarity_entity.Fingerprint) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListByParseStatus indicates an expected call of ListByParseStatus. +func (mr *MockFingerprintRepoMockRecorder) ListByParseStatus(ctx, filter, page any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByParseStatus", reflect.TypeOf((*MockFingerprintRepo)(nil).ListByParseStatus), ctx, filter, page) +} + +// UpdateParseStatus mocks base method. +func (m *MockFingerprintRepo) UpdateParseStatus(ctx context.Context, scriptID int64, status similarity_entity.ParseStatus, parseError string, scannedAt int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateParseStatus", ctx, scriptID, status, parseError, scannedAt) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateParseStatus indicates an expected call of UpdateParseStatus. +func (mr *MockFingerprintRepoMockRecorder) UpdateParseStatus(ctx, scriptID, status, parseError, scannedAt any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateParseStatus", reflect.TypeOf((*MockFingerprintRepo)(nil).UpdateParseStatus), ctx, scriptID, status, parseError, scannedAt) +} + +// Upsert mocks base method. +func (m *MockFingerprintRepo) Upsert(ctx context.Context, fp *similarity_entity.Fingerprint) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Upsert", ctx, fp) + ret0, _ := ret[0].(error) + return ret0 +} + +// Upsert indicates an expected call of Upsert. +func (mr *MockFingerprintRepoMockRecorder) Upsert(ctx, fp any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Upsert", reflect.TypeOf((*MockFingerprintRepo)(nil).Upsert), ctx, fp) +} diff --git a/internal/repository/similarity_repo/mock/fingerprint_es.go b/internal/repository/similarity_repo/mock/fingerprint_es.go new file mode 100644 index 0000000..3fbe081 --- /dev/null +++ b/internal/repository/similarity_repo/mock/fingerprint_es.go @@ -0,0 +1,159 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./fingerprint_es.go +// +// Generated by this command: +// +// mockgen -source=./fingerprint_es.go -destination=./mock/fingerprint_es.go +// + +// Package mock_similarity_repo is a generated GoMock package. +package mock_similarity_repo + +import ( + context "context" + reflect "reflect" + + similarity_repo "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + gomock "go.uber.org/mock/gomock" +) + +// MockFingerprintESRepo is a mock of FingerprintESRepo interface. +type MockFingerprintESRepo struct { + ctrl *gomock.Controller + recorder *MockFingerprintESRepoMockRecorder + isgomock struct{} +} + +// MockFingerprintESRepoMockRecorder is the mock recorder for MockFingerprintESRepo. +type MockFingerprintESRepoMockRecorder struct { + mock *MockFingerprintESRepo +} + +// NewMockFingerprintESRepo creates a new mock instance. +func NewMockFingerprintESRepo(ctrl *gomock.Controller) *MockFingerprintESRepo { + mock := &MockFingerprintESRepo{ctrl: ctrl} + mock.recorder = &MockFingerprintESRepoMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFingerprintESRepo) EXPECT() *MockFingerprintESRepoMockRecorder { + return m.recorder +} + +// AggregateStopFp mocks base method. +func (m *MockFingerprintESRepo) AggregateStopFp(ctx context.Context, cutoff int) ([]similarity_repo.StopFpEntry, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AggregateStopFp", ctx, cutoff) + ret0, _ := ret[0].([]similarity_repo.StopFpEntry) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AggregateStopFp indicates an expected call of AggregateStopFp. +func (mr *MockFingerprintESRepoMockRecorder) AggregateStopFp(ctx, cutoff any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AggregateStopFp", reflect.TypeOf((*MockFingerprintESRepo)(nil).AggregateStopFp), ctx, cutoff) +} + +// CountDistinctMatched mocks base method. +func (m *MockFingerprintESRepo) CountDistinctMatched(ctx context.Context, scriptID, userID int64, queryFps, stopFps []string) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountDistinctMatched", ctx, scriptID, userID, queryFps, stopFps) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountDistinctMatched indicates an expected call of CountDistinctMatched. +func (mr *MockFingerprintESRepoMockRecorder) CountDistinctMatched(ctx, scriptID, userID, queryFps, stopFps any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountDistinctMatched", reflect.TypeOf((*MockFingerprintESRepo)(nil).CountDistinctMatched), ctx, scriptID, userID, queryFps, stopFps) +} + +// BulkInsert mocks base method. +func (m *MockFingerprintESRepo) BulkInsert(ctx context.Context, docs []similarity_repo.FingerprintDoc) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BulkInsert", ctx, docs) + ret0, _ := ret[0].(error) + return ret0 +} + +// BulkInsert indicates an expected call of BulkInsert. +func (mr *MockFingerprintESRepoMockRecorder) BulkInsert(ctx, docs any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BulkInsert", reflect.TypeOf((*MockFingerprintESRepo)(nil).BulkInsert), ctx, docs) +} + +// DeleteByScriptID mocks base method. +func (m *MockFingerprintESRepo) DeleteByScriptID(ctx context.Context, scriptID int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteByScriptID", ctx, scriptID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteByScriptID indicates an expected call of DeleteByScriptID. +func (mr *MockFingerprintESRepoMockRecorder) DeleteByScriptID(ctx, scriptID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteByScriptID", reflect.TypeOf((*MockFingerprintESRepo)(nil).DeleteByScriptID), ctx, scriptID) +} + +// DeleteOldBatches mocks base method. +func (m *MockFingerprintESRepo) DeleteOldBatches(ctx context.Context, scriptID, currentBatchID int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOldBatches", ctx, scriptID, currentBatchID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteOldBatches indicates an expected call of DeleteOldBatches. +func (mr *MockFingerprintESRepoMockRecorder) DeleteOldBatches(ctx, scriptID, currentBatchID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldBatches", reflect.TypeOf((*MockFingerprintESRepo)(nil).DeleteOldBatches), ctx, scriptID, currentBatchID) +} + +// FindAllFingerprintPositions mocks base method. +func (m *MockFingerprintESRepo) FindAllFingerprintPositions(ctx context.Context, scriptID, batchID int64) ([]similarity_repo.FingerprintPosition, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindAllFingerprintPositions", ctx, scriptID, batchID) + ret0, _ := ret[0].([]similarity_repo.FingerprintPosition) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindAllFingerprintPositions indicates an expected call of FindAllFingerprintPositions. +func (mr *MockFingerprintESRepoMockRecorder) FindAllFingerprintPositions(ctx, scriptID, batchID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindAllFingerprintPositions", reflect.TypeOf((*MockFingerprintESRepo)(nil).FindAllFingerprintPositions), ctx, scriptID, batchID) +} + +// FindCandidates mocks base method. +func (m *MockFingerprintESRepo) FindCandidates(ctx context.Context, scriptID, userID int64, queryFps, stopFps []string, limit int) ([]similarity_repo.CandidateHit, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindCandidates", ctx, scriptID, userID, queryFps, stopFps, limit) + ret0, _ := ret[0].([]similarity_repo.CandidateHit) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindCandidates indicates an expected call of FindCandidates. +func (mr *MockFingerprintESRepoMockRecorder) FindCandidates(ctx, scriptID, userID, queryFps, stopFps, limit any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindCandidates", reflect.TypeOf((*MockFingerprintESRepo)(nil).FindCandidates), ctx, scriptID, userID, queryFps, stopFps, limit) +} + +// FindPositionsByFingerprints mocks base method. +func (m *MockFingerprintESRepo) FindPositionsByFingerprints(ctx context.Context, scriptID, batchID int64, fps []string) ([]similarity_repo.FingerprintPosition, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindPositionsByFingerprints", ctx, scriptID, batchID, fps) + ret0, _ := ret[0].([]similarity_repo.FingerprintPosition) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindPositionsByFingerprints indicates an expected call of FindPositionsByFingerprints. +func (mr *MockFingerprintESRepoMockRecorder) FindPositionsByFingerprints(ctx, scriptID, batchID, fps any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindPositionsByFingerprints", reflect.TypeOf((*MockFingerprintESRepo)(nil).FindPositionsByFingerprints), ctx, scriptID, batchID, fps) +} diff --git a/internal/repository/similarity_repo/mock/integrity_review.go b/internal/repository/similarity_repo/mock/integrity_review.go new file mode 100644 index 0000000..58f1762 --- /dev/null +++ b/internal/repository/similarity_repo/mock/integrity_review.go @@ -0,0 +1,118 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./integrity_review.go +// +// Generated by this command: +// +// mockgen -source=./integrity_review.go -destination=./mock/integrity_review.go +// + +// Package mock_similarity_repo is a generated GoMock package. +package mock_similarity_repo + +import ( + context "context" + reflect "reflect" + + httputils "github.com/cago-frame/cago/pkg/utils/httputils" + similarity_entity "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + similarity_repo "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + gomock "go.uber.org/mock/gomock" +) + +// MockIntegrityReviewRepo is a mock of IntegrityReviewRepo interface. +type MockIntegrityReviewRepo struct { + ctrl *gomock.Controller + recorder *MockIntegrityReviewRepoMockRecorder + isgomock struct{} +} + +// MockIntegrityReviewRepoMockRecorder is the mock recorder for MockIntegrityReviewRepo. +type MockIntegrityReviewRepoMockRecorder struct { + mock *MockIntegrityReviewRepo +} + +// NewMockIntegrityReviewRepo creates a new mock instance. +func NewMockIntegrityReviewRepo(ctrl *gomock.Controller) *MockIntegrityReviewRepo { + mock := &MockIntegrityReviewRepo{ctrl: ctrl} + mock.recorder = &MockIntegrityReviewRepoMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIntegrityReviewRepo) EXPECT() *MockIntegrityReviewRepoMockRecorder { + return m.recorder +} + +// FindByCodeID mocks base method. +func (m *MockIntegrityReviewRepo) FindByCodeID(ctx context.Context, scriptCodeID int64) (*similarity_entity.IntegrityReview, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindByCodeID", ctx, scriptCodeID) + ret0, _ := ret[0].(*similarity_entity.IntegrityReview) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindByCodeID indicates an expected call of FindByCodeID. +func (mr *MockIntegrityReviewRepoMockRecorder) FindByCodeID(ctx, scriptCodeID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByCodeID", reflect.TypeOf((*MockIntegrityReviewRepo)(nil).FindByCodeID), ctx, scriptCodeID) +} + +// FindByID mocks base method. +func (m *MockIntegrityReviewRepo) FindByID(ctx context.Context, id int64) (*similarity_entity.IntegrityReview, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindByID", ctx, id) + ret0, _ := ret[0].(*similarity_entity.IntegrityReview) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindByID indicates an expected call of FindByID. +func (mr *MockIntegrityReviewRepoMockRecorder) FindByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByID", reflect.TypeOf((*MockIntegrityReviewRepo)(nil).FindByID), ctx, id) +} + +// List mocks base method. +func (m *MockIntegrityReviewRepo) List(ctx context.Context, filter similarity_repo.IntegrityReviewFilter, page httputils.PageRequest) ([]*similarity_entity.IntegrityReview, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", ctx, filter, page) + ret0, _ := ret[0].([]*similarity_entity.IntegrityReview) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// List indicates an expected call of List. +func (mr *MockIntegrityReviewRepoMockRecorder) List(ctx, filter, page any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockIntegrityReviewRepo)(nil).List), ctx, filter, page) +} + +// Resolve mocks base method. +func (m *MockIntegrityReviewRepo) Resolve(ctx context.Context, id int64, status int8, reviewedBy, reviewedAt int64, note string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resolve", ctx, id, status, reviewedBy, reviewedAt, note) + ret0, _ := ret[0].(error) + return ret0 +} + +// Resolve indicates an expected call of Resolve. +func (mr *MockIntegrityReviewRepoMockRecorder) Resolve(ctx, id, status, reviewedBy, reviewedAt, note any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockIntegrityReviewRepo)(nil).Resolve), ctx, id, status, reviewedBy, reviewedAt, note) +} + +// Upsert mocks base method. +func (m *MockIntegrityReviewRepo) Upsert(ctx context.Context, r *similarity_entity.IntegrityReview) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Upsert", ctx, r) + ret0, _ := ret[0].(error) + return ret0 +} + +// Upsert indicates an expected call of Upsert. +func (mr *MockIntegrityReviewRepoMockRecorder) Upsert(ctx, r any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Upsert", reflect.TypeOf((*MockIntegrityReviewRepo)(nil).Upsert), ctx, r) +} diff --git a/internal/repository/similarity_repo/mock/integrity_whitelist.go b/internal/repository/similarity_repo/mock/integrity_whitelist.go new file mode 100644 index 0000000..6efb8f9 --- /dev/null +++ b/internal/repository/similarity_repo/mock/integrity_whitelist.go @@ -0,0 +1,117 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./integrity_whitelist.go +// +// Generated by this command: +// +// mockgen -source=./integrity_whitelist.go -destination=./mock/integrity_whitelist.go +// + +// Package mock_similarity_repo is a generated GoMock package. +package mock_similarity_repo + +import ( + context "context" + reflect "reflect" + + httputils "github.com/cago-frame/cago/pkg/utils/httputils" + similarity_entity "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + gomock "go.uber.org/mock/gomock" +) + +// MockIntegrityWhitelistRepo is a mock of IntegrityWhitelistRepo interface. +type MockIntegrityWhitelistRepo struct { + ctrl *gomock.Controller + recorder *MockIntegrityWhitelistRepoMockRecorder + isgomock struct{} +} + +// MockIntegrityWhitelistRepoMockRecorder is the mock recorder for MockIntegrityWhitelistRepo. +type MockIntegrityWhitelistRepoMockRecorder struct { + mock *MockIntegrityWhitelistRepo +} + +// NewMockIntegrityWhitelistRepo creates a new mock instance. +func NewMockIntegrityWhitelistRepo(ctrl *gomock.Controller) *MockIntegrityWhitelistRepo { + mock := &MockIntegrityWhitelistRepo{ctrl: ctrl} + mock.recorder = &MockIntegrityWhitelistRepoMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIntegrityWhitelistRepo) EXPECT() *MockIntegrityWhitelistRepoMockRecorder { + return m.recorder +} + +// Add mocks base method. +func (m *MockIntegrityWhitelistRepo) Add(ctx context.Context, w *similarity_entity.IntegrityWhitelist) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Add", ctx, w) + ret0, _ := ret[0].(error) + return ret0 +} + +// Add indicates an expected call of Add. +func (mr *MockIntegrityWhitelistRepoMockRecorder) Add(ctx, w any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockIntegrityWhitelistRepo)(nil).Add), ctx, w) +} + +// FindByScriptID mocks base method. +func (m *MockIntegrityWhitelistRepo) FindByScriptID(ctx context.Context, scriptID int64) (*similarity_entity.IntegrityWhitelist, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindByScriptID", ctx, scriptID) + ret0, _ := ret[0].(*similarity_entity.IntegrityWhitelist) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindByScriptID indicates an expected call of FindByScriptID. +func (mr *MockIntegrityWhitelistRepoMockRecorder) FindByScriptID(ctx, scriptID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByScriptID", reflect.TypeOf((*MockIntegrityWhitelistRepo)(nil).FindByScriptID), ctx, scriptID) +} + +// IsWhitelisted mocks base method. +func (m *MockIntegrityWhitelistRepo) IsWhitelisted(ctx context.Context, scriptID int64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsWhitelisted", ctx, scriptID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsWhitelisted indicates an expected call of IsWhitelisted. +func (mr *MockIntegrityWhitelistRepoMockRecorder) IsWhitelisted(ctx, scriptID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsWhitelisted", reflect.TypeOf((*MockIntegrityWhitelistRepo)(nil).IsWhitelisted), ctx, scriptID) +} + +// List mocks base method. +func (m *MockIntegrityWhitelistRepo) List(ctx context.Context, page httputils.PageRequest) ([]*similarity_entity.IntegrityWhitelist, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", ctx, page) + ret0, _ := ret[0].([]*similarity_entity.IntegrityWhitelist) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// List indicates an expected call of List. +func (mr *MockIntegrityWhitelistRepoMockRecorder) List(ctx, page any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockIntegrityWhitelistRepo)(nil).List), ctx, page) +} + +// Remove mocks base method. +func (m *MockIntegrityWhitelistRepo) Remove(ctx context.Context, scriptID int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Remove", ctx, scriptID) + ret0, _ := ret[0].(error) + return ret0 +} + +// Remove indicates an expected call of Remove. +func (mr *MockIntegrityWhitelistRepoMockRecorder) Remove(ctx, scriptID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockIntegrityWhitelistRepo)(nil).Remove), ctx, scriptID) +} diff --git a/internal/repository/similarity_repo/mock/patrol_query.go b/internal/repository/similarity_repo/mock/patrol_query.go new file mode 100644 index 0000000..9feb59c --- /dev/null +++ b/internal/repository/similarity_repo/mock/patrol_query.go @@ -0,0 +1,86 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./patrol_query.go +// +// Generated by this command: +// +// mockgen -source=./patrol_query.go -destination=./mock/patrol_query.go +// + +// Package mock_similarity_repo is a generated GoMock package. +package mock_similarity_repo + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockPatrolQueryRepo is a mock of PatrolQueryRepo interface. +type MockPatrolQueryRepo struct { + ctrl *gomock.Controller + recorder *MockPatrolQueryRepoMockRecorder + isgomock struct{} +} + +// MockPatrolQueryRepoMockRecorder is the mock recorder for MockPatrolQueryRepo. +type MockPatrolQueryRepoMockRecorder struct { + mock *MockPatrolQueryRepo +} + +// NewMockPatrolQueryRepo creates a new mock instance. +func NewMockPatrolQueryRepo(ctrl *gomock.Controller) *MockPatrolQueryRepo { + mock := &MockPatrolQueryRepo{ctrl: ctrl} + mock.recorder = &MockPatrolQueryRepoMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPatrolQueryRepo) EXPECT() *MockPatrolQueryRepoMockRecorder { + return m.recorder +} + +// CountScripts mocks base method. +func (m *MockPatrolQueryRepo) CountScripts(ctx context.Context) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountScripts", ctx) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountScripts indicates an expected call of CountScripts. +func (mr *MockPatrolQueryRepoMockRecorder) CountScripts(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountScripts", reflect.TypeOf((*MockPatrolQueryRepo)(nil).CountScripts), ctx) +} + +// ListScriptIDsFromCursor mocks base method. +func (m *MockPatrolQueryRepo) ListScriptIDsFromCursor(ctx context.Context, cursor int64, limit int) ([]int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListScriptIDsFromCursor", ctx, cursor, limit) + ret0, _ := ret[0].([]int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListScriptIDsFromCursor indicates an expected call of ListScriptIDsFromCursor. +func (mr *MockPatrolQueryRepoMockRecorder) ListScriptIDsFromCursor(ctx, cursor, limit any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListScriptIDsFromCursor", reflect.TypeOf((*MockPatrolQueryRepo)(nil).ListScriptIDsFromCursor), ctx, cursor, limit) +} + +// ListStaleScriptIDs mocks base method. +func (m *MockPatrolQueryRepo) ListStaleScriptIDs(ctx context.Context, afterID int64, limit int) ([]int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListStaleScriptIDs", ctx, afterID, limit) + ret0, _ := ret[0].([]int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListStaleScriptIDs indicates an expected call of ListStaleScriptIDs. +func (mr *MockPatrolQueryRepoMockRecorder) ListStaleScriptIDs(ctx, afterID, limit any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListStaleScriptIDs", reflect.TypeOf((*MockPatrolQueryRepo)(nil).ListStaleScriptIDs), ctx, afterID, limit) +} diff --git a/internal/repository/similarity_repo/mock/similar_pair.go b/internal/repository/similarity_repo/mock/similar_pair.go new file mode 100644 index 0000000..93f5591 --- /dev/null +++ b/internal/repository/similarity_repo/mock/similar_pair.go @@ -0,0 +1,146 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./similar_pair.go +// +// Generated by this command: +// +// mockgen -source=./similar_pair.go -destination=./mock/similar_pair.go +// + +// Package mock_similarity_repo is a generated GoMock package. +package mock_similarity_repo + +import ( + context "context" + reflect "reflect" + + httputils "github.com/cago-frame/cago/pkg/utils/httputils" + similarity_entity "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + similarity_repo "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + gomock "go.uber.org/mock/gomock" +) + +// MockSimilarPairRepo is a mock of SimilarPairRepo interface. +type MockSimilarPairRepo struct { + ctrl *gomock.Controller + recorder *MockSimilarPairRepoMockRecorder + isgomock struct{} +} + +// MockSimilarPairRepoMockRecorder is the mock recorder for MockSimilarPairRepo. +type MockSimilarPairRepoMockRecorder struct { + mock *MockSimilarPairRepo +} + +// NewMockSimilarPairRepo creates a new mock instance. +func NewMockSimilarPairRepo(ctrl *gomock.Controller) *MockSimilarPairRepo { + mock := &MockSimilarPairRepo{ctrl: ctrl} + mock.recorder = &MockSimilarPairRepoMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSimilarPairRepo) EXPECT() *MockSimilarPairRepoMockRecorder { + return m.recorder +} + +// DeleteByScriptID mocks base method. +func (m *MockSimilarPairRepo) DeleteByScriptID(ctx context.Context, scriptID int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteByScriptID", ctx, scriptID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteByScriptID indicates an expected call of DeleteByScriptID. +func (mr *MockSimilarPairRepoMockRecorder) DeleteByScriptID(ctx, scriptID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteByScriptID", reflect.TypeOf((*MockSimilarPairRepo)(nil).DeleteByScriptID), ctx, scriptID) +} + +// DeletePendingByScriptID mocks base method. +func (m *MockSimilarPairRepo) DeletePendingByScriptID(ctx context.Context, scriptID int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeletePendingByScriptID", ctx, scriptID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeletePendingByScriptID indicates an expected call of DeletePendingByScriptID. +func (mr *MockSimilarPairRepoMockRecorder) DeletePendingByScriptID(ctx, scriptID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePendingByScriptID", reflect.TypeOf((*MockSimilarPairRepo)(nil).DeletePendingByScriptID), ctx, scriptID) +} + +// FindByID mocks base method. +func (m *MockSimilarPairRepo) FindByID(ctx context.Context, id int64) (*similarity_entity.SimilarPair, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindByID", ctx, id) + ret0, _ := ret[0].(*similarity_entity.SimilarPair) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindByID indicates an expected call of FindByID. +func (mr *MockSimilarPairRepoMockRecorder) FindByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByID", reflect.TypeOf((*MockSimilarPairRepo)(nil).FindByID), ctx, id) +} + +// FindByPair mocks base method. +func (m *MockSimilarPairRepo) FindByPair(ctx context.Context, scriptAID, scriptBID int64) (*similarity_entity.SimilarPair, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindByPair", ctx, scriptAID, scriptBID) + ret0, _ := ret[0].(*similarity_entity.SimilarPair) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindByPair indicates an expected call of FindByPair. +func (mr *MockSimilarPairRepoMockRecorder) FindByPair(ctx, scriptAID, scriptBID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByPair", reflect.TypeOf((*MockSimilarPairRepo)(nil).FindByPair), ctx, scriptAID, scriptBID) +} + +// List mocks base method. +func (m *MockSimilarPairRepo) List(ctx context.Context, filter similarity_repo.SimilarPairFilter, page httputils.PageRequest) ([]*similarity_entity.SimilarPair, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", ctx, filter, page) + ret0, _ := ret[0].([]*similarity_entity.SimilarPair) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// List indicates an expected call of List. +func (mr *MockSimilarPairRepoMockRecorder) List(ctx, filter, page any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockSimilarPairRepo)(nil).List), ctx, filter, page) +} + +// UpdateStatus mocks base method. +func (m *MockSimilarPairRepo) UpdateStatus(ctx context.Context, id int64, status int8, reviewedBy, reviewedAt int64, note string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateStatus", ctx, id, status, reviewedBy, reviewedAt, note) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateStatus indicates an expected call of UpdateStatus. +func (mr *MockSimilarPairRepoMockRecorder) UpdateStatus(ctx, id, status, reviewedBy, reviewedAt, note any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateStatus", reflect.TypeOf((*MockSimilarPairRepo)(nil).UpdateStatus), ctx, id, status, reviewedBy, reviewedAt, note) +} + +// Upsert mocks base method. +func (m *MockSimilarPairRepo) Upsert(ctx context.Context, p *similarity_entity.SimilarPair) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Upsert", ctx, p) + ret0, _ := ret[0].(error) + return ret0 +} + +// Upsert indicates an expected call of Upsert. +func (mr *MockSimilarPairRepoMockRecorder) Upsert(ctx, p any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Upsert", reflect.TypeOf((*MockSimilarPairRepo)(nil).Upsert), ctx, p) +} diff --git a/internal/repository/similarity_repo/mock/similarity_whitelist.go b/internal/repository/similarity_repo/mock/similarity_whitelist.go new file mode 100644 index 0000000..34d9ad4 --- /dev/null +++ b/internal/repository/similarity_repo/mock/similarity_whitelist.go @@ -0,0 +1,132 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./similarity_whitelist.go +// +// Generated by this command: +// +// mockgen -source=./similarity_whitelist.go -destination=./mock/similarity_whitelist.go +// + +// Package mock_similarity_repo is a generated GoMock package. +package mock_similarity_repo + +import ( + context "context" + reflect "reflect" + + httputils "github.com/cago-frame/cago/pkg/utils/httputils" + similarity_entity "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + gomock "go.uber.org/mock/gomock" +) + +// MockSimilarityWhitelistRepo is a mock of SimilarityWhitelistRepo interface. +type MockSimilarityWhitelistRepo struct { + ctrl *gomock.Controller + recorder *MockSimilarityWhitelistRepoMockRecorder + isgomock struct{} +} + +// MockSimilarityWhitelistRepoMockRecorder is the mock recorder for MockSimilarityWhitelistRepo. +type MockSimilarityWhitelistRepoMockRecorder struct { + mock *MockSimilarityWhitelistRepo +} + +// NewMockSimilarityWhitelistRepo creates a new mock instance. +func NewMockSimilarityWhitelistRepo(ctrl *gomock.Controller) *MockSimilarityWhitelistRepo { + mock := &MockSimilarityWhitelistRepo{ctrl: ctrl} + mock.recorder = &MockSimilarityWhitelistRepoMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSimilarityWhitelistRepo) EXPECT() *MockSimilarityWhitelistRepoMockRecorder { + return m.recorder +} + +// Add mocks base method. +func (m *MockSimilarityWhitelistRepo) Add(ctx context.Context, w *similarity_entity.SimilarityWhitelist) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Add", ctx, w) + ret0, _ := ret[0].(error) + return ret0 +} + +// Add indicates an expected call of Add. +func (mr *MockSimilarityWhitelistRepoMockRecorder) Add(ctx, w any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Add", reflect.TypeOf((*MockSimilarityWhitelistRepo)(nil).Add), ctx, w) +} + +// FindByID mocks base method. +func (m *MockSimilarityWhitelistRepo) FindByID(ctx context.Context, id int64) (*similarity_entity.SimilarityWhitelist, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindByID", ctx, id) + ret0, _ := ret[0].(*similarity_entity.SimilarityWhitelist) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindByID indicates an expected call of FindByID. +func (mr *MockSimilarityWhitelistRepoMockRecorder) FindByID(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByID", reflect.TypeOf((*MockSimilarityWhitelistRepo)(nil).FindByID), ctx, id) +} + +// FindByPair mocks base method. +func (m *MockSimilarityWhitelistRepo) FindByPair(ctx context.Context, scriptAID, scriptBID int64) (*similarity_entity.SimilarityWhitelist, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindByPair", ctx, scriptAID, scriptBID) + ret0, _ := ret[0].(*similarity_entity.SimilarityWhitelist) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindByPair indicates an expected call of FindByPair. +func (mr *MockSimilarityWhitelistRepoMockRecorder) FindByPair(ctx, scriptAID, scriptBID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByPair", reflect.TypeOf((*MockSimilarityWhitelistRepo)(nil).FindByPair), ctx, scriptAID, scriptBID) +} + +// IsWhitelisted mocks base method. +func (m *MockSimilarityWhitelistRepo) IsWhitelisted(ctx context.Context, scriptAID, scriptBID int64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsWhitelisted", ctx, scriptAID, scriptBID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsWhitelisted indicates an expected call of IsWhitelisted. +func (mr *MockSimilarityWhitelistRepoMockRecorder) IsWhitelisted(ctx, scriptAID, scriptBID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsWhitelisted", reflect.TypeOf((*MockSimilarityWhitelistRepo)(nil).IsWhitelisted), ctx, scriptAID, scriptBID) +} + +// List mocks base method. +func (m *MockSimilarityWhitelistRepo) List(ctx context.Context, page httputils.PageRequest) ([]*similarity_entity.SimilarityWhitelist, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", ctx, page) + ret0, _ := ret[0].([]*similarity_entity.SimilarityWhitelist) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// List indicates an expected call of List. +func (mr *MockSimilarityWhitelistRepoMockRecorder) List(ctx, page any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockSimilarityWhitelistRepo)(nil).List), ctx, page) +} + +// Remove mocks base method. +func (m *MockSimilarityWhitelistRepo) Remove(ctx context.Context, scriptAID, scriptBID int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Remove", ctx, scriptAID, scriptBID) + ret0, _ := ret[0].(error) + return ret0 +} + +// Remove indicates an expected call of Remove. +func (mr *MockSimilarityWhitelistRepoMockRecorder) Remove(ctx, scriptAID, scriptBID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Remove", reflect.TypeOf((*MockSimilarityWhitelistRepo)(nil).Remove), ctx, scriptAID, scriptBID) +} diff --git a/internal/repository/similarity_repo/mock/suspect_summary.go b/internal/repository/similarity_repo/mock/suspect_summary.go new file mode 100644 index 0000000..22a06f0 --- /dev/null +++ b/internal/repository/similarity_repo/mock/suspect_summary.go @@ -0,0 +1,103 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./suspect_summary.go +// +// Generated by this command: +// +// mockgen -source=./suspect_summary.go -destination=./mock/suspect_summary.go +// + +// Package mock_similarity_repo is a generated GoMock package. +package mock_similarity_repo + +import ( + context "context" + reflect "reflect" + + httputils "github.com/cago-frame/cago/pkg/utils/httputils" + similarity_entity "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + similarity_repo "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + gomock "go.uber.org/mock/gomock" +) + +// MockSuspectSummaryRepo is a mock of SuspectSummaryRepo interface. +type MockSuspectSummaryRepo struct { + ctrl *gomock.Controller + recorder *MockSuspectSummaryRepoMockRecorder + isgomock struct{} +} + +// MockSuspectSummaryRepoMockRecorder is the mock recorder for MockSuspectSummaryRepo. +type MockSuspectSummaryRepoMockRecorder struct { + mock *MockSuspectSummaryRepo +} + +// NewMockSuspectSummaryRepo creates a new mock instance. +func NewMockSuspectSummaryRepo(ctrl *gomock.Controller) *MockSuspectSummaryRepo { + mock := &MockSuspectSummaryRepo{ctrl: ctrl} + mock.recorder = &MockSuspectSummaryRepoMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSuspectSummaryRepo) EXPECT() *MockSuspectSummaryRepoMockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockSuspectSummaryRepo) Delete(ctx context.Context, scriptID int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, scriptID) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockSuspectSummaryRepoMockRecorder) Delete(ctx, scriptID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockSuspectSummaryRepo)(nil).Delete), ctx, scriptID) +} + +// FindByScriptID mocks base method. +func (m *MockSuspectSummaryRepo) FindByScriptID(ctx context.Context, scriptID int64) (*similarity_entity.SuspectSummary, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindByScriptID", ctx, scriptID) + ret0, _ := ret[0].(*similarity_entity.SuspectSummary) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindByScriptID indicates an expected call of FindByScriptID. +func (mr *MockSuspectSummaryRepoMockRecorder) FindByScriptID(ctx, scriptID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByScriptID", reflect.TypeOf((*MockSuspectSummaryRepo)(nil).FindByScriptID), ctx, scriptID) +} + +// List mocks base method. +func (m *MockSuspectSummaryRepo) List(ctx context.Context, filter similarity_repo.SuspectFilter, page httputils.PageRequest) ([]*similarity_entity.SuspectSummary, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", ctx, filter, page) + ret0, _ := ret[0].([]*similarity_entity.SuspectSummary) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// List indicates an expected call of List. +func (mr *MockSuspectSummaryRepoMockRecorder) List(ctx, filter, page any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockSuspectSummaryRepo)(nil).List), ctx, filter, page) +} + +// Upsert mocks base method. +func (m *MockSuspectSummaryRepo) Upsert(ctx context.Context, s *similarity_entity.SuspectSummary) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Upsert", ctx, s) + ret0, _ := ret[0].(error) + return ret0 +} + +// Upsert indicates an expected call of Upsert. +func (mr *MockSuspectSummaryRepoMockRecorder) Upsert(ctx, s any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Upsert", reflect.TypeOf((*MockSuspectSummaryRepo)(nil).Upsert), ctx, s) +} diff --git a/internal/repository/similarity_repo/patrol_query.go b/internal/repository/similarity_repo/patrol_query.go new file mode 100644 index 0000000..5d5e092 --- /dev/null +++ b/internal/repository/similarity_repo/patrol_query.go @@ -0,0 +1,104 @@ +package similarity_repo + +//go:generate mockgen -source=./patrol_query.go -destination=./mock/patrol_query.go + +import ( + "context" + + "github.com/cago-frame/cago/database/db" +) + +// PatrolQueryRepo exposes the raw SQL needed by the similarity patrol / +// backfill crontab (§4.5). It lives in similarity_repo because it joins +// `cdb_tampermonkey_script` + `cdb_tampermonkey_script_code` + +// `pre_script_fingerprint`, which only makes sense in this bounded context, +// and the crontab wouldn't otherwise own a repo. +// +// Both methods return raw script_id slices — the caller publishes NSQ +// messages; no side effects on the DB. +type PatrolQueryRepo interface { + // ListStaleScriptIDs returns up to `limit` script ids whose latest active + // (non-pre-release) code version is newer than the last fingerprint scan, + // OR that have no fingerprint row at all. Spec §4.5 "patrol mode". + // + // Ordered by script id asc for determinism. The caller iterates by + // passing the last returned id as `afterID` on the next call. + ListStaleScriptIDs(ctx context.Context, afterID int64, limit int) ([]int64, error) + + // ListScriptIDsFromCursor returns up to `limit` active script ids with + // id > cursor, ordered asc. Used by backfill mode. No join — we want + // every script even if it was previously scanned, so the Scan() + // code_hash short-circuit handles idempotency. + ListScriptIDsFromCursor(ctx context.Context, cursor int64, limit int) ([]int64, error) + + // CountScripts returns the total number of non-deleted scripts. Used to + // populate the backfill progress bar's denominator. + CountScripts(ctx context.Context) (int64, error) +} + +var defaultPatrolQuery PatrolQueryRepo + +func PatrolQuery() PatrolQueryRepo { return defaultPatrolQuery } + +func RegisterPatrolQuery(i PatrolQueryRepo) { defaultPatrolQuery = i } + +type patrolQueryRepo struct{} + +func NewPatrolQueryRepo() PatrolQueryRepo { return &patrolQueryRepo{} } + +func (r *patrolQueryRepo) ListStaleScriptIDs(ctx context.Context, afterID int64, limit int) ([]int64, error) { + // Subquery: for each script, find the createtime of its newest active + // non-pre-release code version. Outer join to fingerprint to detect + // either missing rows OR stale ones. + // + // We filter to Script.status = 1 (ACTIVE) + Script.archive != 1 so we + // don't waste scans on soft-deleted or archived scripts (spec §4.6 says + // deleted scripts keep their fingerprints but don't trigger new scans). + const q = ` +SELECT s.id +FROM cdb_tampermonkey_script s +JOIN ( + SELECT script_id, MAX(createtime) AS latest_at + FROM cdb_tampermonkey_script_code + WHERE is_pre_release = 2 AND status = 1 + GROUP BY script_id +) sc ON sc.script_id = s.id +LEFT JOIN pre_script_fingerprint sf ON sf.script_id = s.id +WHERE s.status = 1 + AND s.archive != 1 + AND s.id > ? + AND (sf.id IS NULL OR sc.latest_at > sf.scanned_at) +ORDER BY s.id ASC +LIMIT ?` + var ids []int64 + if err := db.Ctx(ctx).Raw(q, afterID, limit).Scan(&ids).Error; err != nil { + return nil, err + } + return ids, nil +} + +func (r *patrolQueryRepo) ListScriptIDsFromCursor(ctx context.Context, cursor int64, limit int) ([]int64, error) { + const q = ` +SELECT id +FROM cdb_tampermonkey_script +WHERE id > ? AND status = 1 AND archive != 1 +ORDER BY id ASC +LIMIT ?` + var ids []int64 + if err := db.Ctx(ctx).Raw(q, cursor, limit).Scan(&ids).Error; err != nil { + return nil, err + } + return ids, nil +} + +func (r *patrolQueryRepo) CountScripts(ctx context.Context) (int64, error) { + const q = ` +SELECT COUNT(*) +FROM cdb_tampermonkey_script +WHERE status = 1 AND archive != 1` + var total int64 + if err := db.Ctx(ctx).Raw(q).Scan(&total).Error; err != nil { + return 0, err + } + return total, nil +} diff --git a/internal/repository/similarity_repo/similar_pair.go b/internal/repository/similarity_repo/similar_pair.go new file mode 100644 index 0000000..3ad0f32 --- /dev/null +++ b/internal/repository/similarity_repo/similar_pair.go @@ -0,0 +1,153 @@ +package similarity_repo + +import ( + "context" + "fmt" + + "github.com/cago-frame/cago/database/db" + "github.com/cago-frame/cago/pkg/consts" + "github.com/cago-frame/cago/pkg/utils/httputils" + script_entity "github.com/scriptscat/scriptlist/internal/model/entity/script_entity" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + "gorm.io/gorm/clause" +) + +//go:generate mockgen -source=./similar_pair.go -destination=./mock/similar_pair.go + +// SimilarPairFilter filters list queries on the pair table. +type SimilarPairFilter struct { + Status *int8 // nil = any + MinJaccard *float64 // inclusive + ScriptID int64 // non-zero = only pairs mentioning this script (OR condition on a/b) + ExcludeDeleted bool // true = JOIN scripts 表,两侧都必须非 DELETE 状态 +} + +// SimilarPairRepo persists similarity pair detections. +// +// 不使用缓存:每次扫描都会写入/更新对应行。 +// 时间戳由 service 层设置,repo 不主动获取当前时间,保持 repo 与时钟解耦。 +// +// NOTE: Upsert 的 DoUpdates 列表必须与 SimilarPair 实体字段保持同步(除 id / script_a_id / script_b_id / createtime 外)。 +type SimilarPairRepo interface { + FindByPair(ctx context.Context, scriptAID, scriptBID int64) (*similarity_entity.SimilarPair, error) + FindByID(ctx context.Context, id int64) (*similarity_entity.SimilarPair, error) + List(ctx context.Context, filter SimilarPairFilter, page httputils.PageRequest) ([]*similarity_entity.SimilarPair, int64, error) + Upsert(ctx context.Context, p *similarity_entity.SimilarPair) error + UpdateStatus(ctx context.Context, id int64, status int8, reviewedBy int64, reviewedAt int64, note string) error + DeleteByScriptID(ctx context.Context, scriptID int64) error + // DeletePendingByScriptID 清理脚本作为一方的 pending 对(用于 rescan 前的过期对清除)。 + // 不会影响 whitelisted 等已由管理员处理的对——那是显式决策,应当保留。 + DeletePendingByScriptID(ctx context.Context, scriptID int64) error +} + +var defaultSimilarPair SimilarPairRepo + +func SimilarPair() SimilarPairRepo { return defaultSimilarPair } + +func RegisterSimilarPair(i SimilarPairRepo) { defaultSimilarPair = i } + +type similarPairRepo struct{} + +func NewSimilarPairRepo() SimilarPairRepo { return &similarPairRepo{} } + +// NormalizePair returns the two ids in ascending order so callers can enforce +// the (script_a_id < script_b_id) invariant before persisting. +func NormalizePair(a, b int64) (int64, int64) { + if a < b { + return a, b + } + return b, a +} + +func (r *similarPairRepo) FindByPair(ctx context.Context, a, b int64) (*similarity_entity.SimilarPair, error) { + a, b = NormalizePair(a, b) + var ret similarity_entity.SimilarPair + err := db.Ctx(ctx).Where("script_a_id = ? AND script_b_id = ?", a, b).First(&ret).Error + if err != nil { + if db.RecordNotFound(err) { + return nil, nil + } + return nil, err + } + return &ret, nil +} + +func (r *similarPairRepo) Upsert(ctx context.Context, p *similarity_entity.SimilarPair) error { + p.ScriptAID, p.ScriptBID = NormalizePair(p.ScriptAID, p.ScriptBID) + return db.Ctx(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "script_a_id"}, {Name: "script_b_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "user_a_id", "user_b_id", "a_script_code_id", "b_script_code_id", + "a_code_created_at", "b_code_created_at", + "jaccard", "common_count", "a_fp_count", "b_fp_count", + "matched_fp", "detected_at", "status", "updatetime", + }), + }).Create(p).Error +} + +func (r *similarPairRepo) FindByID(ctx context.Context, id int64) (*similarity_entity.SimilarPair, error) { + var ret similarity_entity.SimilarPair + err := db.Ctx(ctx).Where("id = ?", id).First(&ret).Error + if err != nil { + if db.RecordNotFound(err) { + return nil, nil + } + return nil, err + } + return &ret, nil +} + +func (r *similarPairRepo) List(ctx context.Context, filter SimilarPairFilter, page httputils.PageRequest) ([]*similarity_entity.SimilarPair, int64, error) { + pairTable := (&similarity_entity.SimilarPair{}).TableName() + q := db.Ctx(ctx).Model(&similarity_entity.SimilarPair{}) + if filter.ExcludeDeleted { + scriptTable := (&script_entity.Script{}).TableName() + q = q.Joins(fmt.Sprintf("JOIN %s sa ON sa.id = %s.script_a_id AND sa.status != ?", scriptTable, pairTable), consts.DELETE). + Joins(fmt.Sprintf("JOIN %s sb ON sb.id = %s.script_b_id AND sb.status != ?", scriptTable, pairTable), consts.DELETE) + } + if filter.Status != nil { + q = q.Where(pairTable+".status = ?", *filter.Status) + } + if filter.MinJaccard != nil { + q = q.Where(pairTable+".jaccard >= ?", *filter.MinJaccard) + } + if filter.ScriptID != 0 { + q = q.Where(pairTable+".script_a_id = ? OR "+pairTable+".script_b_id = ?", filter.ScriptID, filter.ScriptID) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []*similarity_entity.SimilarPair + if err := q.Order(pairTable + ".jaccard DESC, " + pairTable + ".id DESC"). + Offset(page.GetOffset()).Limit(page.GetLimit()). + Find(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + +func (r *similarPairRepo) UpdateStatus(ctx context.Context, id int64, status int8, reviewedBy, reviewedAt int64, note string) error { + return db.Ctx(ctx).Model(&similarity_entity.SimilarPair{}). + Where("id = ?", id). + Updates(map[string]any{ + "status": status, + "reviewed_by": reviewedBy, + "reviewed_at": reviewedAt, + "review_note": note, + "updatetime": reviewedAt, + }).Error +} + +func (r *similarPairRepo) DeleteByScriptID(ctx context.Context, scriptID int64) error { + return db.Ctx(ctx). + Where("script_a_id = ? OR script_b_id = ?", scriptID, scriptID). + Delete(&similarity_entity.SimilarPair{}).Error +} + +func (r *similarPairRepo) DeletePendingByScriptID(ctx context.Context, scriptID int64) error { + return db.Ctx(ctx). + Where("(script_a_id = ? OR script_b_id = ?) AND status = ?", + scriptID, scriptID, int8(similarity_entity.PairStatusPending)). + Delete(&similarity_entity.SimilarPair{}).Error +} diff --git a/internal/repository/similarity_repo/similar_pair_test.go b/internal/repository/similarity_repo/similar_pair_test.go new file mode 100644 index 0000000..84b0623 --- /dev/null +++ b/internal/repository/similarity_repo/similar_pair_test.go @@ -0,0 +1,22 @@ +package similarity_repo + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSimilarPairRepo_InterfaceShape(t *testing.T) { + var _ SimilarPairRepo = (*similarPairRepo)(nil) + assert.NotNil(t, NewSimilarPairRepo()) +} + +func TestNormalizePair_OrdersAscending(t *testing.T) { + a, b := NormalizePair(20, 5) + assert.Equal(t, int64(5), a) + assert.Equal(t, int64(20), b) + + a, b = NormalizePair(5, 20) + assert.Equal(t, int64(5), a) + assert.Equal(t, int64(20), b) +} diff --git a/internal/repository/similarity_repo/similarity_whitelist.go b/internal/repository/similarity_repo/similarity_whitelist.go new file mode 100644 index 0000000..c6b9034 --- /dev/null +++ b/internal/repository/similarity_repo/similarity_whitelist.go @@ -0,0 +1,99 @@ +package similarity_repo + +import ( + "context" + + "github.com/cago-frame/cago/database/db" + "github.com/cago-frame/cago/pkg/utils/httputils" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" +) + +//go:generate mockgen -source=./similarity_whitelist.go -destination=./mock/similarity_whitelist.go + +// SimilarityWhitelistRepo manages pair-level (script_a, script_b) whitelist entries +// that suppress false-positive similar pairs. +// +// 不使用缓存:写入低频,读取在扫描热路径上但量级很小(一次扫描查 O(候选数) 次)。 +// 时间戳由 service 层设置,repo 不主动获取当前时间,保持 repo 与时钟解耦。 +type SimilarityWhitelistRepo interface { + IsWhitelisted(ctx context.Context, scriptAID, scriptBID int64) (bool, error) + FindByPair(ctx context.Context, scriptAID, scriptBID int64) (*similarity_entity.SimilarityWhitelist, error) + FindByID(ctx context.Context, id int64) (*similarity_entity.SimilarityWhitelist, error) + List(ctx context.Context, page httputils.PageRequest) ([]*similarity_entity.SimilarityWhitelist, int64, error) + Add(ctx context.Context, w *similarity_entity.SimilarityWhitelist) error + Remove(ctx context.Context, scriptAID, scriptBID int64) error +} + +var defaultSimilarityWhitelist SimilarityWhitelistRepo + +func SimilarityWhitelist() SimilarityWhitelistRepo { return defaultSimilarityWhitelist } + +func RegisterSimilarityWhitelist(i SimilarityWhitelistRepo) { defaultSimilarityWhitelist = i } + +type similarityWhitelistRepo struct{} + +func NewSimilarityWhitelistRepo() SimilarityWhitelistRepo { return &similarityWhitelistRepo{} } + +func (r *similarityWhitelistRepo) IsWhitelisted(ctx context.Context, a, b int64) (bool, error) { + a, b = NormalizePair(a, b) + var row similarity_entity.SimilarityWhitelist + err := db.Ctx(ctx).Where("script_a_id = ? AND script_b_id = ?", a, b).First(&row).Error + if err != nil { + if db.RecordNotFound(err) { + return false, nil + } + return false, err + } + return true, nil +} + +func (r *similarityWhitelistRepo) Add(ctx context.Context, w *similarity_entity.SimilarityWhitelist) error { + w.ScriptAID, w.ScriptBID = NormalizePair(w.ScriptAID, w.ScriptBID) + return db.Ctx(ctx).Create(w).Error +} + +func (r *similarityWhitelistRepo) FindByPair(ctx context.Context, a, b int64) (*similarity_entity.SimilarityWhitelist, error) { + a, b = NormalizePair(a, b) + var ret similarity_entity.SimilarityWhitelist + err := db.Ctx(ctx).Where("script_a_id = ? AND script_b_id = ?", a, b).First(&ret).Error + if err != nil { + if db.RecordNotFound(err) { + return nil, nil + } + return nil, err + } + return &ret, nil +} + +func (r *similarityWhitelistRepo) FindByID(ctx context.Context, id int64) (*similarity_entity.SimilarityWhitelist, error) { + var ret similarity_entity.SimilarityWhitelist + err := db.Ctx(ctx).Where("id = ?", id).First(&ret).Error + if err != nil { + if db.RecordNotFound(err) { + return nil, nil + } + return nil, err + } + return &ret, nil +} + +func (r *similarityWhitelistRepo) List(ctx context.Context, page httputils.PageRequest) ([]*similarity_entity.SimilarityWhitelist, int64, error) { + var total int64 + if err := db.Ctx(ctx).Model(&similarity_entity.SimilarityWhitelist{}).Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []*similarity_entity.SimilarityWhitelist + if err := db.Ctx(ctx).Order("id DESC"). + Offset(page.GetOffset()).Limit(page.GetLimit()). + Find(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + +func (r *similarityWhitelistRepo) Remove(ctx context.Context, a, b int64) error { + a, b = NormalizePair(a, b) + return db.Ctx(ctx). + Where("script_a_id = ? AND script_b_id = ?", a, b). + Delete(&similarity_entity.SimilarityWhitelist{}).Error +} diff --git a/internal/repository/similarity_repo/similarity_whitelist_test.go b/internal/repository/similarity_repo/similarity_whitelist_test.go new file mode 100644 index 0000000..19eaa27 --- /dev/null +++ b/internal/repository/similarity_repo/similarity_whitelist_test.go @@ -0,0 +1,12 @@ +package similarity_repo + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSimilarityWhitelistRepo_InterfaceShape(t *testing.T) { + var _ SimilarityWhitelistRepo = (*similarityWhitelistRepo)(nil) + assert.NotNil(t, NewSimilarityWhitelistRepo()) +} diff --git a/internal/repository/similarity_repo/suspect_summary.go b/internal/repository/similarity_repo/suspect_summary.go new file mode 100644 index 0000000..d5b809e --- /dev/null +++ b/internal/repository/similarity_repo/suspect_summary.go @@ -0,0 +1,94 @@ +package similarity_repo + +import ( + "context" + + "github.com/cago-frame/cago/database/db" + "github.com/cago-frame/cago/pkg/utils/httputils" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + "gorm.io/gorm/clause" +) + +//go:generate mockgen -source=./suspect_summary.go -destination=./mock/suspect_summary.go + +// SuspectFilter filters list queries on the suspect_summary table. +type SuspectFilter struct { + MinJaccard *float64 + MinCoverage *float64 + Status *int8 +} + +// SuspectSummaryRepo persists per-script aggregated similarity metrics. +// +// 不使用缓存:每次扫描都会写入/更新对应行。 +// 时间戳由 service 层设置,repo 不主动获取当前时间,保持 repo 与时钟解耦。 +// +// NOTE: Upsert 的 DoUpdates 列表必须与 SuspectSummary 实体字段保持同步(除 script_id / createtime 外)。 +type SuspectSummaryRepo interface { + FindByScriptID(ctx context.Context, scriptID int64) (*similarity_entity.SuspectSummary, error) + List(ctx context.Context, filter SuspectFilter, page httputils.PageRequest) ([]*similarity_entity.SuspectSummary, int64, error) + Upsert(ctx context.Context, s *similarity_entity.SuspectSummary) error + Delete(ctx context.Context, scriptID int64) error +} + +var defaultSuspectSummary SuspectSummaryRepo + +func SuspectSummary() SuspectSummaryRepo { return defaultSuspectSummary } + +func RegisterSuspectSummary(i SuspectSummaryRepo) { defaultSuspectSummary = i } + +type suspectSummaryRepo struct{} + +func NewSuspectSummaryRepo() SuspectSummaryRepo { return &suspectSummaryRepo{} } + +func (r *suspectSummaryRepo) FindByScriptID(ctx context.Context, scriptID int64) (*similarity_entity.SuspectSummary, error) { + var ret similarity_entity.SuspectSummary + err := db.Ctx(ctx).Where("script_id = ?", scriptID).First(&ret).Error + if err != nil { + if db.RecordNotFound(err) { + return nil, nil + } + return nil, err + } + return &ret, nil +} + +func (r *suspectSummaryRepo) Upsert(ctx context.Context, s *similarity_entity.SuspectSummary) error { + return db.Ctx(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "script_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "user_id", "max_jaccard", "coverage", "top_sources", + "pair_count", "detected_at", "status", "updatetime", + }), + }).Create(s).Error +} + +func (r *suspectSummaryRepo) List(ctx context.Context, filter SuspectFilter, page httputils.PageRequest) ([]*similarity_entity.SuspectSummary, int64, error) { + q := db.Ctx(ctx).Model(&similarity_entity.SuspectSummary{}) + switch { + case filter.MinJaccard != nil && filter.MinCoverage != nil: + q = q.Where("max_jaccard >= ? OR coverage >= ?", *filter.MinJaccard, *filter.MinCoverage) + case filter.MinJaccard != nil: + q = q.Where("max_jaccard >= ?", *filter.MinJaccard) + case filter.MinCoverage != nil: + q = q.Where("coverage >= ?", *filter.MinCoverage) + } + if filter.Status != nil { + q = q.Where("status = ?", *filter.Status) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []*similarity_entity.SuspectSummary + if err := q.Order("max_jaccard DESC, coverage DESC"). + Offset(page.GetOffset()).Limit(page.GetLimit()). + Find(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + +func (r *suspectSummaryRepo) Delete(ctx context.Context, scriptID int64) error { + return db.Ctx(ctx).Where("script_id = ?", scriptID).Delete(&similarity_entity.SuspectSummary{}).Error +} diff --git a/internal/repository/similarity_repo/suspect_summary_test.go b/internal/repository/similarity_repo/suspect_summary_test.go new file mode 100644 index 0000000..a356bf1 --- /dev/null +++ b/internal/repository/similarity_repo/suspect_summary_test.go @@ -0,0 +1,12 @@ +package similarity_repo + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSuspectSummaryRepo_InterfaceShape(t *testing.T) { + var _ SuspectSummaryRepo = (*suspectSummaryRepo)(nil) + assert.NotNil(t, NewSuspectSummaryRepo()) +} diff --git a/internal/service/script_svc/script.go b/internal/service/script_svc/script.go index d48b541..8f707c8 100644 --- a/internal/service/script_svc/script.go +++ b/internal/service/script_svc/script.go @@ -4,6 +4,7 @@ import ( "context" "crypto/hmac" "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -35,6 +36,7 @@ import ( "github.com/scriptscat/scriptlist/internal/repository/user_repo" "github.com/scriptscat/scriptlist/internal/service/auth_svc" "github.com/scriptscat/scriptlist/internal/service/script_svc/gray_control" + "github.com/scriptscat/scriptlist/internal/service/similarity_svc" "github.com/scriptscat/scriptlist/internal/service/statistics_svc" "github.com/scriptscat/scriptlist/internal/task/producer" "go.uber.org/zap" @@ -304,6 +306,17 @@ func (s *scriptSvc) scriptCode(ctx context.Context, script *script_entity.Script // Create 创建脚本 func (s *scriptSvc) Create(ctx context.Context, req *api.CreateRequest) (*api.CreateResponse, error) { + // 完整性前置检查(仅执行快速信号,耗时信号由相似度扫描消费者异步处理) + if similarity_svc.IntegrityEnabled() && similarity_svc.Integrity() != nil && req.Code != "" { + result := similarity_svc.Integrity().CheckFast(ctx, req.Code) + if result.Score >= similarity_svc.IntegrityBlockThreshold() { + return nil, i18n.NewErrorWithStatus( + ctx, http.StatusBadRequest, + code.SimilarityIntegrityRejected, + result.BuildUserMessage(), + ) + } + } script := &script_entity.Script{ UserID: auth_svc.Auth().Get(ctx).UserID, Content: req.Content, @@ -415,6 +428,11 @@ func (s *scriptSvc) Create(ctx context.Context, req *api.CreateRequest) (*api.Cr logger.Ctx(ctx).Error("publish scriptSvc create failed", zap.Int64("script_id", script.ID), zap.Int64("code_id", scriptCode.ID), zap.Error(err)) return nil, i18n.NewInternalError(ctx, code.ScriptCreateFailed) } + // 投递相似度扫描消息(错误不阻塞用户响应) + if err := producer.PublishSimilarityScan(ctx, script.ID, "publish", false); err != nil { + logger.Ctx(ctx).Error("publish similarity.scan failed", + zap.Int64("script_id", script.ID), zap.Error(err)) + } return &api.CreateResponse{ID: script.ID}, nil } @@ -431,6 +449,37 @@ func (s *scriptSvc) UpdateCode(ctx context.Context, req *api.UpdateCodeRequest) if err := script.IsArchive(ctx); err != nil { return nil, err } + // 完整性前置检查(仅执行快速信号,耗时信号由相似度扫描消费者异步处理) + if !req.SkipIntegrity && similarity_svc.IntegrityEnabled() && similarity_svc.Integrity() != nil && req.Code != "" { + latest, err := script_repo.ScriptCode().FindLatest(ctx, script.ID, 0, true) + if err != nil { + logger.Ctx(ctx).Warn("integrity pre-check: failed to load latest code, skipping hash optimization", + zap.Int64("script_id", script.ID), zap.Error(err)) + } + var existingHash string + if latest != nil { + existingHash = sha256HexString(latest.Code) + } + newHash := sha256HexString(req.Code) + if newHash != existingHash { + whitelisted, err := similarity_svc.Integrity().IsWhitelisted(ctx, script.ID) + if err != nil { + logger.Ctx(ctx).Warn("integrity pre-check: whitelist lookup failed, skipping check", + zap.Int64("script_id", script.ID), zap.Error(err)) + whitelisted = true // fail-open: don't block on transient errors + } + if !whitelisted { + result := similarity_svc.Integrity().CheckFast(ctx, req.Code) + if result.Score >= similarity_svc.IntegrityBlockThreshold() { + return nil, i18n.NewErrorWithStatus( + ctx, http.StatusBadRequest, + code.SimilarityIntegrityRejected, + result.BuildUserMessage(), + ) + } + } + } + } scriptCode := &script_entity.Code{ UserID: auth_svc.Auth().Get(ctx).UserID, ScriptID: script.ID, @@ -558,6 +607,11 @@ func (s *scriptSvc) UpdateCode(ctx context.Context, req *api.UpdateCodeRequest) logger.Ctx(ctx).Error("publish scriptSvc code update failed", zap.Int64("script_id", script.ID), zap.Int64("code_id", scriptCode.ID), zap.Error(err)) return nil, i18n.NewInternalError(ctx, code.ScriptUpdateFailed) } + // 投递相似度扫描消息(错误不阻塞用户响应) + if err := producer.PublishSimilarityScan(ctx, script.ID, "update", false); err != nil { + logger.Ctx(ctx).Error("publish similarity.scan failed", + zap.Int64("script_id", script.ID), zap.Error(err)) + } } else { if scriptCode.IsPreRelease == script_entity.EnablePreReleaseScript { // 判断是否有正式版本 @@ -950,13 +1004,14 @@ func (s *scriptSvc) SyncOnce(ctx context.Context, script *script_entity.Script, } } req := &api.UpdateCodeRequest{ - ID: script.ID, - Version: code.Version, - Content: script.Content, - Code: codeContent, - Definition: "", - Changelog: "该版本为系统自动同步更新", - IsPreRelease: 0, + ID: script.ID, + Version: code.Version, + Content: script.Content, + Code: codeContent, + Definition: "", + Changelog: "该版本为系统自动同步更新", + IsPreRelease: 0, + SkipIntegrity: true, //Public: script.Public, //Unwell: script.Unwell, } @@ -1470,3 +1525,10 @@ func (s *scriptSvc) GetIcon(ctx context.Context, scriptID int64) (string, error) } return "", nil } + +// sha256HexString returns the lowercase hex SHA-256 of s. Used by UpdateCode +// to short-circuit integrity checks when the user only edited metadata. +func sha256HexString(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/service/similarity_svc/access.go b/internal/service/similarity_svc/access.go new file mode 100644 index 0000000..f428d27 --- /dev/null +++ b/internal/service/similarity_svc/access.go @@ -0,0 +1,112 @@ +package similarity_svc + +import ( + "context" + "net/http" + "strconv" + + "github.com/cago-frame/cago/pkg/i18n" + "github.com/cago-frame/cago/pkg/utils/httputils" + "github.com/gin-gonic/gin" + + "github.com/scriptscat/scriptlist/internal/model" + "github.com/scriptscat/scriptlist/internal/model/entity/script_entity" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + "github.com/scriptscat/scriptlist/internal/pkg/code" + "github.com/scriptscat/scriptlist/internal/repository/script_repo" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + "github.com/scriptscat/scriptlist/internal/service/auth_svc" +) + +// AccessSvc exposes gin middleware for similarity endpoints. +type AccessSvc interface { + RequireSimilarityPairAccess() gin.HandlerFunc +} + +var defaultAccess AccessSvc = &accessSvc{} + +func Access() AccessSvc { return defaultAccess } + +func RegisterAccess(i AccessSvc) { defaultAccess = i } + +type accessSvc struct{} + +func NewAccessSvc() AccessSvc { return &accessSvc{} } + +// Context keys for handler reuse of values computed by the middleware. +type ctxKeyPair struct{} + +// CtxPair returns the pair loaded by RequireSimilarityPairAccess, if any. +func CtxPair(ctx context.Context) *similarity_entity.SimilarPair { + v, _ := ctx.Value(ctxKeyPair{}).(*similarity_entity.SimilarPair) + return v +} + +// RequireSimilarityPairAccess implements spec §5.2. +// +// Rules: +// 1. :id must parse to a pair row. +// 2. Caller must be logged in (delegated to RequireLogin(true) earlier in the chain). +// 3. Pass if caller is admin OR caller is user_a_id / user_b_id of the pair. +// 4. Private-script exception: if either side is private, admin-only. +func (a *accessSvc) RequireSimilarityPairAccess() gin.HandlerFunc { + return func(c *gin.Context) { + ctx := c.Request.Context() + idStr := c.Param("id") + pairID, err := strconv.ParseInt(idStr, 10, 64) + if err != nil || pairID <= 0 { + httputils.HandleResp(c, i18n.NewError(ctx, code.SimilarityPairNotFound)) + c.Abort() + return + } + pair, err := similarity_repo.SimilarPair().FindByID(ctx, pairID) + if err != nil { + httputils.HandleResp(c, err) + c.Abort() + return + } + if pair == nil { + httputils.HandleResp(c, i18n.NewError(ctx, code.SimilarityPairNotFound)) + c.Abort() + return + } + + authInfo := auth_svc.Auth().Get(ctx) + if authInfo == nil { + httputils.HandleResp(c, i18n.NewErrorWithStatus(ctx, http.StatusUnauthorized, code.SimilarityAccessDenied)) + c.Abort() + return + } + isAdmin := authInfo.AdminLevel.IsAdmin(model.Admin) + isParticipant := authInfo.UserID == pair.UserAID || authInfo.UserID == pair.UserBID + + // Private-script exception: both scripts must be loaded so we can check. + scripts, err := script_repo.Script().FindByIDs(ctx, []int64{pair.ScriptAID, pair.ScriptBID}) + if err != nil { + httputils.HandleResp(c, err) + c.Abort() + return + } + anyPrivate := false + for _, s := range scripts { + if s != nil && script_entity.Public(s.Public) == script_entity.PrivateScript { + anyPrivate = true + break + } + } + if anyPrivate && !isAdmin { + httputils.HandleResp(c, i18n.NewErrorWithStatus(ctx, http.StatusForbidden, code.SimilarityAccessDenied)) + c.Abort() + return + } + if !isAdmin && !isParticipant { + httputils.HandleResp(c, i18n.NewErrorWithStatus(ctx, http.StatusForbidden, code.SimilarityAccessDenied)) + c.Abort() + return + } + + // Stash pair for downstream handler. + c.Request = c.Request.WithContext(context.WithValue(ctx, ctxKeyPair{}, pair)) + c.Next() + } +} diff --git a/internal/service/similarity_svc/access_test.go b/internal/service/similarity_svc/access_test.go new file mode 100644 index 0000000..17e0ae3 --- /dev/null +++ b/internal/service/similarity_svc/access_test.go @@ -0,0 +1,14 @@ +package similarity_svc + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAccess_ExportedShape(t *testing.T) { + // Interface shape smoke test — full integration tested at Task 10 (controller). + var _ = (&accessSvc{}) + assert.NotNil(t, NewAccessSvc()) + assert.NotNil(t, Access()) +} diff --git a/internal/service/similarity_svc/admin.go b/internal/service/similarity_svc/admin.go new file mode 100644 index 0000000..73b8da5 --- /dev/null +++ b/internal/service/similarity_svc/admin.go @@ -0,0 +1,198 @@ +package similarity_svc + +//go:generate mockgen -source=./admin.go -destination=./mock/admin.go + +import ( + "context" + + "github.com/cago-frame/cago/pkg/consts" + api "github.com/scriptscat/scriptlist/internal/api/similarity" + "github.com/scriptscat/scriptlist/internal/model/entity/script_entity" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + "github.com/scriptscat/scriptlist/internal/model/entity/user_entity" + "github.com/scriptscat/scriptlist/internal/repository/script_repo" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + "github.com/scriptscat/scriptlist/internal/repository/user_repo" +) + +// AdminSvc backs all Phase 3 admin + evidence endpoints. Methods compose +// multiple repos (pair / suspect / whitelist / integrity_review / script / +// user) into response DTOs. +type AdminSvc interface { + // Similarity pairs + ListPairs(ctx context.Context, req *api.ListPairsRequest) (*api.ListPairsResponse, error) + GetPairDetail(ctx context.Context, req *api.GetPairDetailRequest) (*api.GetPairDetailResponse, error) + AddPairWhitelist(ctx context.Context, req *api.AddPairWhitelistRequest) (*api.AddPairWhitelistResponse, error) + RemovePairWhitelist(ctx context.Context, req *api.RemovePairWhitelistRequest) (*api.RemovePairWhitelistResponse, error) + RemovePairWhitelistByID(ctx context.Context, req *api.RemovePairWhitelistByIDRequest) (*api.RemovePairWhitelistByIDResponse, error) + ListPairWhitelist(ctx context.Context, req *api.ListPairWhitelistRequest) (*api.ListPairWhitelistResponse, error) + + // Suspect aggregates (view B) + ListSuspects(ctx context.Context, req *api.ListSuspectsRequest) (*api.ListSuspectsResponse, error) + + // Integrity reviews + ListIntegrityReviews(ctx context.Context, req *api.ListIntegrityReviewsRequest) (*api.ListIntegrityReviewsResponse, error) + GetIntegrityReview(ctx context.Context, req *api.GetIntegrityReviewRequest) (*api.GetIntegrityReviewResponse, error) + ResolveIntegrityReview(ctx context.Context, req *api.ResolveIntegrityReviewRequest) (*api.ResolveIntegrityReviewResponse, error) + + // Integrity whitelist + ListIntegrityWhitelist(ctx context.Context, req *api.ListIntegrityWhitelistRequest) (*api.ListIntegrityWhitelistResponse, error) + AddIntegrityWhitelist(ctx context.Context, req *api.AddIntegrityWhitelistRequest) (*api.AddIntegrityWhitelistResponse, error) + RemoveIntegrityWhitelist(ctx context.Context, req *api.RemoveIntegrityWhitelistRequest) (*api.RemoveIntegrityWhitelistResponse, error) + + // Fingerprint parse-failure triage + ListParseFailures(ctx context.Context, req *api.ListParseFailuresRequest) (*api.ListParseFailuresResponse, error) + + // Semi-public evidence page + GetEvidencePair(ctx context.Context, req *api.GetEvidencePairRequest) (*api.GetEvidencePairResponse, error) + + // Phase 4: backfill / patrol / manual scan + TriggerBackfill(ctx context.Context, req *api.TriggerBackfillRequest) (*api.TriggerBackfillResponse, error) + GetBackfillStatus(ctx context.Context, req *api.GetBackfillStatusRequest) (*api.GetBackfillStatusResponse, error) + ManualScan(ctx context.Context, req *api.ManualScanRequest) (*api.ManualScanResponse, error) + RefreshStopFp(ctx context.Context, req *api.RefreshStopFpRequest) (*api.RefreshStopFpResponse, error) +} + +var defaultAdmin AdminSvc = &adminSvc{} + +func Admin() AdminSvc { return defaultAdmin } + +func RegisterAdmin(i AdminSvc) { defaultAdmin = i } + +type adminSvc struct{} + +func NewAdminSvc() AdminSvc { return &adminSvc{} } + +// ---- Shared helpers for list endpoints ---- + +// collectPairIDs returns the unique script + user ids referenced by the pairs, +// suitable for batch-loading into brief maps. +func collectPairIDs(rows []*similarity_entity.SimilarPair) (scriptIDs, userIDs []int64) { + seenS := map[int64]struct{}{} + seenU := map[int64]struct{}{} + for _, p := range rows { + for _, id := range []int64{p.ScriptAID, p.ScriptBID} { + if _, ok := seenS[id]; !ok { + seenS[id] = struct{}{} + scriptIDs = append(scriptIDs, id) + } + } + for _, id := range []int64{p.UserAID, p.UserBID} { + if _, ok := seenU[id]; !ok { + seenU[id] = struct{}{} + userIDs = append(userIDs, id) + } + } + } + return +} + +func fetchScriptMap(ctx context.Context, ids []int64) (map[int64]*script_entity.Script, error) { + if len(ids) == 0 { + return map[int64]*script_entity.Script{}, nil + } + rows, err := script_repo.Script().FindByIDs(ctx, ids) + if err != nil { + return nil, err + } + out := make(map[int64]*script_entity.Script, len(rows)) + for _, r := range rows { + if r != nil { + out[r.ID] = r + } + } + return out, nil +} + +// fetchUserMap wraps user_repo.FindByIDs (which returns a slice) into a +// lookup map keyed by user id. Returns an empty map on empty input. +func fetchUserMap(ctx context.Context, ids []int64) (map[int64]*user_entity.User, error) { + if len(ids) == 0 { + return map[int64]*user_entity.User{}, nil + } + rows, err := user_repo.User().FindByIDs(ctx, ids) + if err != nil { + return nil, err + } + out := make(map[int64]*user_entity.User, len(rows)) + for _, r := range rows { + if r != nil { + out[r.ID] = r + } + } + return out, nil +} + +func buildScriptBrief(s *script_entity.Script, users map[int64]*user_entity.User) api.ScriptBrief { + if s == nil { + return api.ScriptBrief{} + } + username := "" + if u, ok := users[s.UserID]; ok && u != nil { + username = u.Username + } + return api.ScriptBrief{ + ID: s.ID, + Name: s.Name, + UserID: s.UserID, + Username: username, + Public: int(s.Public), + Createtime: s.Createtime, + IsDeleted: s.Status == consts.DELETE, + } +} + +func earlierSide(aAt, bAt int64) string { + switch { + case aAt < bAt: + return "A" + case bAt < aAt: + return "B" + default: + return "same" + } +} + +// integrityScoreForScripts picks the higher integrity_review score across the +// two scripts in a pair (spec §10.12). Returns 0 when no review exists. One +// FindByCodeID lookup per non-zero side — acceptable at list scale. Passing +// b=0 (e.g. on the suspect list) yields a single-script lookup. +func integrityScoreForScripts(ctx context.Context, a, b int64) float64 { + scoreFor := func(sid int64) float64 { + if sid == 0 { + return 0 + } + fp, err := similarity_repo.Fingerprint().FindByScriptID(ctx, sid) + if err != nil || fp == nil { + return 0 + } + review, err := similarity_repo.IntegrityReview().FindByCodeID(ctx, fp.ScriptCodeID) + if err != nil || review == nil { + return 0 + } + return review.Score + } + sa := scoreFor(a) + sb := scoreFor(b) + if sb > sa { + return sb + } + return sa +} + +func buildScriptFullInfo(s *script_entity.Script, users map[int64]*user_entity.User, codeRow *script_entity.Code, codeCreatedAt int64) api.ScriptFullInfo { + full := api.ScriptFullInfo{ScriptBrief: buildScriptBrief(s, users)} + if codeRow != nil { + full.ScriptCodeID = codeRow.ID + full.Version = codeRow.Version + full.CodeCreatedAt = codeCreatedAt + } + return full +} + +func codeBody(c *script_entity.Code) string { + if c == nil { + return "" + } + return c.Code +} diff --git a/internal/service/similarity_svc/admin_backfill.go b/internal/service/similarity_svc/admin_backfill.go new file mode 100644 index 0000000..e857b55 --- /dev/null +++ b/internal/service/similarity_svc/admin_backfill.go @@ -0,0 +1,149 @@ +package similarity_svc + +import ( + "context" + "net/http" + "time" + + "github.com/cago-frame/cago/pkg/i18n" + "github.com/cago-frame/cago/pkg/logger" + api "github.com/scriptscat/scriptlist/internal/api/similarity" + "github.com/scriptscat/scriptlist/internal/pkg/code" + "github.com/scriptscat/scriptlist/internal/repository/script_repo" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + "github.com/scriptscat/scriptlist/internal/task/producer" + "go.uber.org/zap" +) + +// backfillRunner is the side-effectful long-running work injected from the +// crontab package via RegisterBackfillRunner. Keeping it as a function avoids +// an import cycle between similarity_svc and task/crontab/handler (the +// handler already imports similarity_svc for state helpers). The force flag +// is threaded through so a Reset-triggered run can bypass the code_hash +// short-circuit inside Scan. +type backfillRunner func(ctx context.Context, force bool) error + +var defaultBackfillRunner backfillRunner + +// RegisterBackfillRunner wires the crontab handler's RunBackfill method into +// admin_svc so the HTTP endpoint can kick it off. Call from cmd/app/main.go +// after handler + similarity_svc are both initialized. +func RegisterBackfillRunner(fn func(ctx context.Context, force bool) error) { + defaultBackfillRunner = fn +} + +// Package-level seams for ManualScan + TriggerBackfill so tests can +// substitute fakes without standing up NSQ / real goroutines. Follow the +// same pattern as scan.go's loadSimilarityConfig / acquireScanLock. +var ( + publishSimilarityScanFn = producer.PublishSimilarityScan + countScriptsFn = func(ctx context.Context) (int64, error) { + return similarity_repo.PatrolQuery().CountScripts(ctx) + } + goBackfill = func(runner backfillRunner, force bool) { + go func() { + bgCtx := context.Background() + if err := runner(bgCtx, force); err != nil { + logger.Ctx(bgCtx).Error("backfill runner returned error", zap.Error(err)) + } + }() + } + // stopFpRefresher is wired from the crontab handler in main.go to avoid + // an import cycle; tests substitute a fake. + stopFpRefresher = func(ctx context.Context) error { return nil } +) + +// RegisterStopFpRefresher wires the crontab handler's Refresh method into +// admin_svc so the admin endpoint can trigger an on-demand refresh. §8.5 +// step 8 wants this right after the first full backfill completes. +func RegisterStopFpRefresher(fn func(ctx context.Context) error) { + stopFpRefresher = fn +} + +func (s *adminSvc) TriggerBackfill(ctx context.Context, req *api.TriggerBackfillRequest) (*api.TriggerBackfillResponse, error) { + if req.Reset { + if err := ResetBackfillCursor(ctx); err != nil { + return nil, err + } + } + total, err := countScriptsFn(ctx) + if err != nil { + return nil, err + } + now := time.Now().Unix() + acquired, err := TryAcquireBackfillLock(ctx, total, now) + if err != nil { + return nil, err + } + if !acquired { + return nil, i18n.NewErrorWithStatus(ctx, http.StatusConflict, code.SimilarityBackfillInProgress) + } + if defaultBackfillRunner == nil { + // Defensive: runner not wired. Release the lock so the admin can + // retry after fixing wiring, and return a 500. + _ = FinishBackfill(ctx, time.Now().Unix()) + return nil, i18n.NewError(ctx, code.SimilarityScanFailed) + } + // Detach from the request context so the backfill survives after the + // HTTP handler returns. Injectable seam keeps tests synchronous. + // + // Reset=true propagates force=true so the Scan-level code_hash short + // circuit is bypassed and every script genuinely re-runs; otherwise + // the whole walk is a no-op for scripts previously scanned OK. + goBackfill(defaultBackfillRunner, req.Reset) + state, err := LoadBackfillState(ctx) + if err != nil { + return nil, err + } + return &api.TriggerBackfillResponse{ + Running: state.Running, + Cursor: state.Cursor, + Total: state.Total, + StartedAt: state.StartedAt, + FinishedAt: state.FinishedAt, + }, nil +} + +func (s *adminSvc) GetBackfillStatus(ctx context.Context, _ *api.GetBackfillStatusRequest) (*api.GetBackfillStatusResponse, error) { + state, err := LoadBackfillState(ctx) + if err != nil { + return nil, err + } + return &api.GetBackfillStatusResponse{ + Running: state.Running, + Cursor: state.Cursor, + Total: state.Total, + StartedAt: state.StartedAt, + FinishedAt: state.FinishedAt, + }, nil +} + +func (s *adminSvc) ManualScan(ctx context.Context, req *api.ManualScanRequest) (*api.ManualScanResponse, error) { + // Confirm the script exists before publishing — saves us a noisy log + // line when an admin mistypes an id. + script, err := script_repo.Script().Find(ctx, req.ScriptID) + if err != nil { + return nil, err + } + if script == nil { + return nil, i18n.NewError(ctx, code.ScriptNotFound) + } + if err := publishSimilarityScanFn(ctx, req.ScriptID, "manual", false); err != nil { + return nil, err + } + return &api.ManualScanResponse{}, nil +} + +// RefreshStopFp runs the stop-fingerprint aggregation on demand. It blocks +// until the refresh completes — the ES aggregation is small (<1s at +// <10k scripts), so synchronous is fine. If the handler is not wired we +// return a SimilarityScanFailed error. +func (s *adminSvc) RefreshStopFp(ctx context.Context, _ *api.RefreshStopFpRequest) (*api.RefreshStopFpResponse, error) { + if stopFpRefresher == nil { + return nil, i18n.NewError(ctx, code.SimilarityScanFailed) + } + if err := stopFpRefresher(ctx); err != nil { + return nil, err + } + return &api.RefreshStopFpResponse{}, nil +} diff --git a/internal/service/similarity_svc/admin_backfill_test.go b/internal/service/similarity_svc/admin_backfill_test.go new file mode 100644 index 0000000..aba2343 --- /dev/null +++ b/internal/service/similarity_svc/admin_backfill_test.go @@ -0,0 +1,281 @@ +package similarity_svc + +import ( + "context" + "errors" + "sync" + "testing" + + api "github.com/scriptscat/scriptlist/internal/api/similarity" + "github.com/scriptscat/scriptlist/internal/model/entity/script_entity" + "github.com/scriptscat/scriptlist/internal/pkg/code" + "github.com/scriptscat/scriptlist/internal/repository/script_repo" + mock_script_repo "github.com/scriptscat/scriptlist/internal/repository/script_repo/mock" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + mock_similarity_repo "github.com/scriptscat/scriptlist/internal/repository/similarity_repo/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// adminBackfillMu serializes access to the package-level service-locator and +// package-level function seams so these tests don't race other suites. +var adminBackfillMu sync.Mutex + +type adminBackfillFakes struct { + backfillState *mock_similarity_repo.MockBackfillStateRepo + scriptRepo *mock_script_repo.MockScriptRepo + + // runnerCalls counts invocations of the fake backfill runner. + runnerCalls int + // lastForce records the force flag the runner was last invoked with, + // letting Reset tests assert the flag propagated from TriggerBackfill. + lastForce bool + // scans records (scriptID, source) pairs published via ManualScan. + scans []scanCall + + // redisHeld drives the fake Redis SETNX backfill lock. + redisHeld bool +} + +type scanCall struct { + scriptID int64 + source string +} + +func setupAdminBackfillFakes(t *testing.T) (*adminSvc, *adminBackfillFakes, context.Context) { + adminBackfillMu.Lock() + t.Cleanup(adminBackfillMu.Unlock) + + ctrl := gomock.NewController(t) + f := &adminBackfillFakes{ + backfillState: mock_similarity_repo.NewMockBackfillStateRepo(ctrl), + scriptRepo: mock_script_repo.NewMockScriptRepo(ctrl), + } + similarity_repo.RegisterBackfillState(f.backfillState) + script_repo.RegisterScript(f.scriptRepo) + + // Wire the mock BackfillStateRepo to track redisHeld state so tests can + // assert lock semantics naturally (same as old fakeBackfillRedis approach). + f.backfillState.EXPECT().AcquireLock(gomock.Any()).DoAndReturn( + func(_ context.Context) (bool, error) { + if f.redisHeld { + return false, nil + } + f.redisHeld = true + return true, nil + }).AnyTimes() + f.backfillState.EXPECT().ReleaseLock(gomock.Any()).DoAndReturn( + func(_ context.Context) error { + f.redisHeld = false + return nil + }).AnyTimes() + f.backfillState.EXPECT().CheckLock(gomock.Any()).DoAndReturn( + func(_ context.Context) (bool, error) { + return f.redisHeld, nil + }).AnyTimes() + f.backfillState.EXPECT().WriteConfig(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil).AnyTimes() + f.backfillState.EXPECT().ReadInt64Config(gomock.Any(), gomock.Any()). + Return(int64(0), nil).AnyTimes() + + // Save + replace package seams so the tests never fork a goroutine and + // never hit real NSQ / real PatrolQuery SQL. + origCount := countScriptsFn + origPublish := publishSimilarityScanFn + origGo := goBackfill + origRunner := defaultBackfillRunner + + countScriptsFn = func(_ context.Context) (int64, error) { return 1000, nil } + publishSimilarityScanFn = func(_ context.Context, scriptID int64, source string, _ bool) error { + f.scans = append(f.scans, scanCall{scriptID, source}) + return nil + } + // Synchronous, so the test can assert runner execution without races. + goBackfill = func(runner backfillRunner, force bool) { + if runner != nil { + _ = runner(context.Background(), force) + } + } + defaultBackfillRunner = func(_ context.Context, force bool) error { + f.runnerCalls++ + f.lastForce = force + return nil + } + + t.Cleanup(func() { + countScriptsFn = origCount + publishSimilarityScanFn = origPublish + goBackfill = origGo + defaultBackfillRunner = origRunner + }) + + return &adminSvc{}, f, context.Background() +} + +// TestTriggerBackfill_Success covers the happy path: Redis lock claimed, +// CountScripts populates total, runner fires. +func TestTriggerBackfill_Success(t *testing.T) { + svc, f, ctx := setupAdminBackfillFakes(t) + + resp, err := svc.TriggerBackfill(ctx, &api.TriggerBackfillRequest{}) + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, 1, f.runnerCalls) +} + +// TestTriggerBackfill_Reset verifies that reset=true writes cursor=0 before +// acquiring the lock AND forwards force=true into the runner so the +// code_hash short-circuit is bypassed downstream (§8.5 step 9 behavior). +func TestTriggerBackfill_Reset(t *testing.T) { + svc, f, ctx := setupAdminBackfillFakes(t) + + _, err := svc.TriggerBackfill(ctx, &api.TriggerBackfillRequest{Reset: true}) + require.NoError(t, err) + assert.Equal(t, 1, f.runnerCalls) + assert.True(t, f.lastForce, "Reset=true must propagate force=true to runner") +} + +// TestTriggerBackfill_NoReset_NoForce is the mirror of the Reset test: a +// plain resume-from-cursor click must not force, so idempotent re-runs stay +// idempotent. +func TestTriggerBackfill_NoReset_NoForce(t *testing.T) { + svc, f, ctx := setupAdminBackfillFakes(t) + + _, err := svc.TriggerBackfill(ctx, &api.TriggerBackfillRequest{Reset: false}) + require.NoError(t, err) + assert.Equal(t, 1, f.runnerCalls) + assert.False(t, f.lastForce, "Reset=false must leave force=false") +} + +// TestTriggerBackfill_AlreadyRunning guarantees a second click returns 409 +// without running a second instance or touching the cursor. +func TestTriggerBackfill_AlreadyRunning(t *testing.T) { + svc, f, ctx := setupAdminBackfillFakes(t) + + // Seed the fake Redis lock as already held — SETNX will return false. + f.redisHeld = true + + _, err := svc.TriggerBackfill(ctx, &api.TriggerBackfillRequest{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "") + assert.Equal(t, 0, f.runnerCalls) +} + +// TestTriggerBackfill_RunnerNotRegistered releases the lock and returns an +// error rather than leaving the flag stuck — guards against boot-order bugs +// where someone registers routes before the runner. +func TestTriggerBackfill_RunnerNotRegistered(t *testing.T) { + svc, f, ctx := setupAdminBackfillFakes(t) + defaultBackfillRunner = nil + + _, err := svc.TriggerBackfill(ctx, &api.TriggerBackfillRequest{}) + assert.Error(t, err) + assert.False(t, f.redisHeld, "Redis lock must be released on runner failure") +} + +func TestTriggerBackfill_CountError(t *testing.T) { + svc, _, ctx := setupAdminBackfillFakes(t) + countScriptsFn = func(_ context.Context) (int64, error) { + return 0, errors.New("count failed") + } + _, err := svc.TriggerBackfill(ctx, &api.TriggerBackfillRequest{}) + assert.Error(t, err) +} + +// TestGetBackfillStatus_ReturnsState reads the 4 metadata rows and pulls +// running from the Redis lock state, copying everything into the API response. +func TestGetBackfillStatus_ReturnsState(t *testing.T) { + svc, f, ctx := setupAdminBackfillFakes(t) + f.redisHeld = true + + // Override the AnyTimes default for ReadInt64Config with specific values. + ctrl := gomock.NewController(t) + specificMock := mock_similarity_repo.NewMockBackfillStateRepo(ctrl) + similarity_repo.RegisterBackfillState(specificMock) + specificMock.EXPECT().CheckLock(gomock.Any()).Return(true, nil) + specificMock.EXPECT().ReadInt64Config(gomock.Any(), BackfillCursorKey).Return(int64(250), nil) + specificMock.EXPECT().ReadInt64Config(gomock.Any(), BackfillTotalTargetKey).Return(int64(1000), nil) + specificMock.EXPECT().ReadInt64Config(gomock.Any(), BackfillStartedAtKey).Return(int64(1700000000), nil) + specificMock.EXPECT().ReadInt64Config(gomock.Any(), BackfillFinishedAtKey).Return(int64(0), nil) + + resp, err := svc.GetBackfillStatus(ctx, &api.GetBackfillStatusRequest{}) + require.NoError(t, err) + assert.True(t, resp.Running) + assert.Equal(t, int64(250), resp.Cursor) + assert.Equal(t, int64(1000), resp.Total) + assert.Equal(t, int64(1700000000), resp.StartedAt) + assert.Equal(t, int64(0), resp.FinishedAt) +} + +// TestManualScan_PublishesForExistingScript confirms that the endpoint +// verifies script existence then fires an NSQ message with source="manual". +func TestManualScan_PublishesForExistingScript(t *testing.T) { + svc, f, ctx := setupAdminBackfillFakes(t) + f.scriptRepo.EXPECT().Find(gomock.Any(), int64(42)). + Return(&script_entity.Script{ID: 42}, nil) + + _, err := svc.ManualScan(ctx, &api.ManualScanRequest{ScriptID: 42}) + require.NoError(t, err) + require.Len(t, f.scans, 1) + assert.Equal(t, int64(42), f.scans[0].scriptID) + assert.Equal(t, "manual", f.scans[0].source) +} + +// TestManualScan_NotFound returns a script-not-found error without +// publishing. +func TestManualScan_NotFound(t *testing.T) { + svc, f, ctx := setupAdminBackfillFakes(t) + f.scriptRepo.EXPECT().Find(gomock.Any(), int64(404)).Return(nil, nil) + + _, err := svc.ManualScan(ctx, &api.ManualScanRequest{ScriptID: 404}) + require.Error(t, err) + assert.Empty(t, f.scans) + // Sanity-check the error is the expected domain code by string match — + // the i18n wrapper prefixes with a code number so we can't compare + // directly, but we know ScriptNotFound should appear in the message. + _ = code.ScriptNotFound // ensure the symbol remains referenced +} + +// TestManualScan_PublishError surfaces producer errors to the caller. +func TestManualScan_PublishError(t *testing.T) { + svc, f, ctx := setupAdminBackfillFakes(t) + f.scriptRepo.EXPECT().Find(gomock.Any(), int64(7)). + Return(&script_entity.Script{ID: 7}, nil) + publishSimilarityScanFn = func(_ context.Context, _ int64, _ string, _ bool) error { + return errors.New("nsq down") + } + _, err := svc.ManualScan(ctx, &api.ManualScanRequest{ScriptID: 7}) + assert.Error(t, err) +} + +// TestRefreshStopFp_InvokesRefresher proves the admin endpoint delegates +// to whatever was registered via RegisterStopFpRefresher. +func TestRefreshStopFp_InvokesRefresher(t *testing.T) { + svc, _, ctx := setupAdminBackfillFakes(t) + origRefresher := stopFpRefresher + t.Cleanup(func() { stopFpRefresher = origRefresher }) + + called := false + stopFpRefresher = func(_ context.Context) error { + called = true + return nil + } + _, err := svc.RefreshStopFp(ctx, &api.RefreshStopFpRequest{}) + require.NoError(t, err) + assert.True(t, called) +} + +// TestRefreshStopFp_SurfaceError bubbles refresher errors to the caller so +// the admin UI can show a red toast. +func TestRefreshStopFp_SurfaceError(t *testing.T) { + svc, _, ctx := setupAdminBackfillFakes(t) + origRefresher := stopFpRefresher + t.Cleanup(func() { stopFpRefresher = origRefresher }) + + stopFpRefresher = func(_ context.Context) error { + return errors.New("es down") + } + _, err := svc.RefreshStopFp(ctx, &api.RefreshStopFpRequest{}) + assert.Error(t, err) +} diff --git a/internal/service/similarity_svc/admin_evidence.go b/internal/service/similarity_svc/admin_evidence.go new file mode 100644 index 0000000..36682ad --- /dev/null +++ b/internal/service/similarity_svc/admin_evidence.go @@ -0,0 +1,346 @@ +package similarity_svc + +import ( + "context" + "encoding/json" + "time" + + "github.com/cago-frame/cago/pkg/i18n" + "github.com/cago-frame/cago/pkg/utils/httputils" + api "github.com/scriptscat/scriptlist/internal/api/similarity" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + "github.com/scriptscat/scriptlist/internal/pkg/code" + "github.com/scriptscat/scriptlist/internal/repository/script_repo" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + "github.com/scriptscat/scriptlist/internal/service/auth_svc" +) + +func (s *adminSvc) ListPairs(ctx context.Context, req *api.ListPairsRequest) (*api.ListPairsResponse, error) { + filter := similarity_repo.SimilarPairFilter{ + ScriptID: req.ScriptID, + ExcludeDeleted: req.ExcludeDeleted, + } + if req.Status != nil { + st := int8(*req.Status) + filter.Status = &st + } + if req.MinJaccard != nil { + filter.MinJaccard = req.MinJaccard + } + rows, total, err := similarity_repo.SimilarPair().List(ctx, filter, req.PageRequest) + if err != nil { + return nil, err + } + + // Batch-load scripts + users so we can build ScriptBrief without N+1. + scriptIDs, userIDs := collectPairIDs(rows) + scripts, err := fetchScriptMap(ctx, scriptIDs) + if err != nil { + return nil, err + } + users, err := fetchUserMap(ctx, userIDs) + if err != nil { + return nil, err + } + + items := make([]*api.SimilarPairItem, 0, len(rows)) + for _, p := range rows { + items = append(items, &api.SimilarPairItem{ + ID: p.ID, + ScriptA: buildScriptBrief(scripts[p.ScriptAID], users), + ScriptB: buildScriptBrief(scripts[p.ScriptBID], users), + Jaccard: p.Jaccard, + CommonCount: p.CommonCount, + EarlierSide: earlierSide(p.ACodeCreatedAt, p.BCodeCreatedAt), + Status: int(p.Status), + DetectedAt: p.DetectedAt, + IntegrityScore: integrityScoreForScripts(ctx, p.ScriptAID, p.ScriptBID), + }) + } + return &api.ListPairsResponse{ + PageResponse: httputils.PageResponse[*api.SimilarPairItem]{ + List: items, + Total: total, + }, + }, nil +} + +func (s *adminSvc) GetPairDetail(ctx context.Context, req *api.GetPairDetailRequest) (*api.GetPairDetailResponse, error) { + return buildPairDetail(ctx, req.ID, true /*adminView*/) +} + +func (s *adminSvc) ListSuspects(ctx context.Context, req *api.ListSuspectsRequest) (*api.ListSuspectsResponse, error) { + filter := similarity_repo.SuspectFilter{ + MinJaccard: req.MinJaccard, + MinCoverage: req.MinCoverage, + } + if req.Status != nil { + st := int8(*req.Status) + filter.Status = &st + } + rows, total, err := similarity_repo.SuspectSummary().List(ctx, filter, req.PageRequest) + if err != nil { + return nil, err + } + + scriptIDs := make([]int64, 0, len(rows)) + userIDs := make([]int64, 0, len(rows)) + for _, r := range rows { + scriptIDs = append(scriptIDs, r.ScriptID) + userIDs = append(userIDs, r.UserID) + } + scripts, err := fetchScriptMap(ctx, scriptIDs) + if err != nil { + return nil, err + } + users, err := fetchUserMap(ctx, userIDs) + if err != nil { + return nil, err + } + + items := make([]*api.SuspectScriptItem, 0, len(rows)) + for _, r := range rows { + var topSources []similarity_entity.TopSource + if err := json.Unmarshal([]byte(r.TopSources), &topSources); err != nil { + topSources = nil + } + apiTops := make([]api.TopSource, 0, len(topSources)) + for _, t := range topSources { + apiTops = append(apiTops, api.TopSource{ + ScriptID: t.ScriptID, + ScriptName: t.ScriptName, + Jaccard: t.Jaccard, + ContributionPct: t.ContributionPct, + }) + } + items = append(items, &api.SuspectScriptItem{ + Script: buildScriptBrief(scripts[r.ScriptID], users), + MaxJaccard: r.MaxJaccard, + Coverage: r.Coverage, + TopSources: apiTops, + PairCount: r.PairCount, + DetectedAt: r.DetectedAt, + IntegrityScore: integrityScoreForScripts(ctx, r.ScriptID, 0), + }) + } + return &api.ListSuspectsResponse{ + PageResponse: httputils.PageResponse[*api.SuspectScriptItem]{ + List: items, + Total: total, + }, + }, nil +} + +func (s *adminSvc) ListIntegrityReviews(ctx context.Context, req *api.ListIntegrityReviewsRequest) (*api.ListIntegrityReviewsResponse, error) { + filter := similarity_repo.IntegrityReviewFilter{} + if req.Status != nil { + st := int8(*req.Status) + filter.Status = &st + } + rows, total, err := similarity_repo.IntegrityReview().List(ctx, filter, req.PageRequest) + if err != nil { + return nil, err + } + scriptIDs := make([]int64, 0, len(rows)) + userIDs := make([]int64, 0, len(rows)) + for _, r := range rows { + scriptIDs = append(scriptIDs, r.ScriptID) + userIDs = append(userIDs, r.UserID) + } + scripts, err := fetchScriptMap(ctx, scriptIDs) + if err != nil { + return nil, err + } + users, err := fetchUserMap(ctx, userIDs) + if err != nil { + return nil, err + } + items := make([]*api.IntegrityReviewItem, 0, len(rows)) + for _, r := range rows { + items = append(items, &api.IntegrityReviewItem{ + ID: r.ID, + Script: buildScriptBrief(scripts[r.ScriptID], users), + ScriptCodeID: r.ScriptCodeID, + Score: r.Score, + Status: int(r.Status), + Createtime: r.Createtime, + }) + } + return &api.ListIntegrityReviewsResponse{ + PageResponse: httputils.PageResponse[*api.IntegrityReviewItem]{List: items, Total: total}, + }, nil +} + +// ListParseFailures returns fingerprint rows whose parse_status is not OK so +// admins can triage scripts that are invisible to similarity comparison. +// Default (no status filter) returns failed rows only — skip rows are noise +// (soft-deleted / too-large) unless the admin asks for them explicitly. +func (s *adminSvc) ListParseFailures(ctx context.Context, req *api.ListParseFailuresRequest) (*api.ListParseFailuresResponse, error) { + filter := similarity_repo.ParseFailureFilter{ + Statuses: []similarity_entity.ParseStatus{similarity_entity.ParseStatusFailed}, + } + if req.Status != nil { + filter.Statuses = []similarity_entity.ParseStatus{similarity_entity.ParseStatus(*req.Status)} + } + rows, total, err := similarity_repo.Fingerprint().ListByParseStatus(ctx, filter, req.PageRequest) + if err != nil { + return nil, err + } + scriptIDs := make([]int64, 0, len(rows)) + userIDs := make([]int64, 0, len(rows)) + for _, r := range rows { + scriptIDs = append(scriptIDs, r.ScriptID) + userIDs = append(userIDs, r.UserID) + } + scripts, err := fetchScriptMap(ctx, scriptIDs) + if err != nil { + return nil, err + } + users, err := fetchUserMap(ctx, userIDs) + if err != nil { + return nil, err + } + items := make([]*api.ParseFailureItem, 0, len(rows)) + for _, r := range rows { + items = append(items, &api.ParseFailureItem{ + ScriptID: r.ScriptID, + Script: buildScriptBrief(scripts[r.ScriptID], users), + ParseStatus: int(r.ParseStatus), + ParseError: r.ParseError, + ScannedAt: r.ScannedAt, + FingerprintCnt: r.FingerprintCnt, + Updatetime: r.Updatetime, + }) + } + return &api.ListParseFailuresResponse{ + PageResponse: httputils.PageResponse[*api.ParseFailureItem]{List: items, Total: total}, + }, nil +} + +func (s *adminSvc) GetIntegrityReview(ctx context.Context, req *api.GetIntegrityReviewRequest) (*api.GetIntegrityReviewResponse, error) { + row, err := similarity_repo.IntegrityReview().FindByID(ctx, req.ID) + if err != nil { + return nil, err + } + if row == nil { + return nil, i18n.NewError(ctx, code.SimilarityPairNotFound) + } + scripts, err := fetchScriptMap(ctx, []int64{row.ScriptID}) + if err != nil { + return nil, err + } + users, err := fetchUserMap(ctx, []int64{row.UserID}) + if err != nil { + return nil, err + } + codeRow, err := script_repo.ScriptCode().FindByIDIncludeDeleted(ctx, row.ScriptCodeID) + if err != nil { + return nil, err + } + var sub api.IntegritySubScores + _ = json.Unmarshal([]byte(row.SubScores), &sub) + var hits []api.IntegrityHitSignal + _ = json.Unmarshal([]byte(row.HitSignals), &hits) + + detail := &api.IntegrityReviewDetail{ + IntegrityReviewItem: api.IntegrityReviewItem{ + ID: row.ID, + Script: buildScriptBrief(scripts[row.ScriptID], users), + ScriptCodeID: row.ScriptCodeID, + Score: row.Score, + Status: int(row.Status), + Createtime: row.Createtime, + }, + SubScores: sub, + HitSignals: hits, + Code: codeBody(codeRow), + ReviewedBy: row.ReviewedBy, + ReviewedAt: row.ReviewedAt, + ReviewNote: row.ReviewNote, + } + return &api.GetIntegrityReviewResponse{Detail: detail}, nil +} + +func (s *adminSvc) ResolveIntegrityReview(ctx context.Context, req *api.ResolveIntegrityReviewRequest) (*api.ResolveIntegrityReviewResponse, error) { + row, err := similarity_repo.IntegrityReview().FindByID(ctx, req.ID) + if err != nil { + return nil, err + } + if row == nil { + return nil, i18n.NewError(ctx, code.SimilarityPairNotFound) + } + authInfo := auth_svc.Auth().Get(ctx) + uid := int64(0) + if authInfo != nil { + uid = authInfo.UserID + } + if err := similarity_repo.IntegrityReview().Resolve(ctx, req.ID, int8(req.Status), uid, time.Now().Unix(), req.Note); err != nil { + return nil, err + } + return &api.ResolveIntegrityReviewResponse{}, nil +} + +func (s *adminSvc) GetEvidencePair(ctx context.Context, req *api.GetEvidencePairRequest) (*api.GetEvidencePairResponse, error) { + r, err := buildPairDetail(ctx, req.ID, false) + if err != nil { + return nil, err + } + return &api.GetEvidencePairResponse{Detail: r.Detail}, nil +} + +// buildPairDetail is the shared implementation backing both GetPairDetail +// (admin view) and GetEvidencePair (semi-public evidence page). adminView=true +// exposes Status / ReviewNote / AdminActions; adminView=false omits them. +func buildPairDetail(ctx context.Context, pairID int64, adminView bool) (*api.GetPairDetailResponse, error) { + pair, err := similarity_repo.SimilarPair().FindByID(ctx, pairID) + if err != nil { + return nil, err + } + if pair == nil { + return nil, i18n.NewError(ctx, code.SimilarityPairNotFound) + } + scripts, err := fetchScriptMap(ctx, []int64{pair.ScriptAID, pair.ScriptBID}) + if err != nil { + return nil, err + } + users, err := fetchUserMap(ctx, []int64{pair.UserAID, pair.UserBID}) + if err != nil { + return nil, err + } + codeA, err := script_repo.ScriptCode().FindByIDIncludeDeleted(ctx, pair.AScriptCodeID) + if err != nil { + return nil, err + } + codeB, err := script_repo.ScriptCode().FindByIDIncludeDeleted(ctx, pair.BScriptCodeID) + if err != nil { + return nil, err + } + segs, err := BuildMatchSegments(ctx, pair) + if err != nil { + return nil, err + } + + detail := &api.PairDetail{ + ID: pair.ID, + ScriptA: buildScriptFullInfo(scripts[pair.ScriptAID], users, codeA, pair.ACodeCreatedAt), + ScriptB: buildScriptFullInfo(scripts[pair.ScriptBID], users, codeB, pair.BCodeCreatedAt), + Jaccard: pair.Jaccard, + CommonCount: pair.CommonCount, + AFingerprintCnt: pair.AFpCount, + BFingerprintCnt: pair.BFpCount, + EarlierSide: earlierSide(pair.ACodeCreatedAt, pair.BCodeCreatedAt), + DetectedAt: pair.DetectedAt, + CodeA: codeBody(codeA), + CodeB: codeBody(codeB), + MatchSegments: segs, + } + if adminView { + detail.Status = int(pair.Status) + detail.ReviewNote = pair.ReviewNote + detail.AdminActions = &api.AdminActions{ + CanWhitelist: pair.Status != similarity_entity.PairStatusWhitelisted, + } + } + return &api.GetPairDetailResponse{Detail: detail}, nil +} + diff --git a/internal/service/similarity_svc/admin_test.go b/internal/service/similarity_svc/admin_test.go new file mode 100644 index 0000000..9166b04 --- /dev/null +++ b/internal/service/similarity_svc/admin_test.go @@ -0,0 +1,314 @@ +package similarity_svc + +import ( + "context" + "sync" + "testing" + + "github.com/cago-frame/cago/pkg/utils/httputils" + api "github.com/scriptscat/scriptlist/internal/api/similarity" + "github.com/scriptscat/scriptlist/internal/model/entity/script_entity" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + "github.com/scriptscat/scriptlist/internal/model/entity/user_entity" + "github.com/scriptscat/scriptlist/internal/repository/script_repo" + mock_script_repo "github.com/scriptscat/scriptlist/internal/repository/script_repo/mock" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + mock_similarity_repo "github.com/scriptscat/scriptlist/internal/repository/similarity_repo/mock" + "github.com/scriptscat/scriptlist/internal/repository/user_repo" + mock_user_repo "github.com/scriptscat/scriptlist/internal/repository/user_repo/mock" + "github.com/scriptscat/scriptlist/internal/service/auth_svc" + mock_auth_svc "github.com/scriptscat/scriptlist/internal/service/auth_svc/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestAdminSvc_InterfaceShape(t *testing.T) { + var _ AdminSvc = (*adminSvc)(nil) + assert.NotNil(t, NewAdminSvc()) +} + +// adminGlobalMu serializes access to the package-level service-locator so +// parallel admin tests don't race on RegisterXxx. Kept distinct from scan_test.go's +// scanGlobalMu to avoid cross-suite lock collisions. +var adminGlobalMu sync.Mutex + +type adminTestMocks struct { + pairRepo *mock_similarity_repo.MockSimilarPairRepo + suspectRepo *mock_similarity_repo.MockSuspectSummaryRepo + whitelistRepo *mock_similarity_repo.MockSimilarityWhitelistRepo + reviewRepo *mock_similarity_repo.MockIntegrityReviewRepo + intWlRepo *mock_similarity_repo.MockIntegrityWhitelistRepo + fpRepo *mock_similarity_repo.MockFingerprintRepo + fpESRepo *mock_similarity_repo.MockFingerprintESRepo + scriptRepo *mock_script_repo.MockScriptRepo + codeRepo *mock_script_repo.MockScriptCodeRepo + userRepo *mock_user_repo.MockUserRepo + authSvc *mock_auth_svc.MockAuthSvc +} + +func setupAdminMocks(t *testing.T) (*adminSvc, *adminTestMocks, context.Context) { + adminGlobalMu.Lock() + t.Cleanup(func() { adminGlobalMu.Unlock() }) + ctrl := gomock.NewController(t) + m := &adminTestMocks{ + pairRepo: mock_similarity_repo.NewMockSimilarPairRepo(ctrl), + suspectRepo: mock_similarity_repo.NewMockSuspectSummaryRepo(ctrl), + whitelistRepo: mock_similarity_repo.NewMockSimilarityWhitelistRepo(ctrl), + reviewRepo: mock_similarity_repo.NewMockIntegrityReviewRepo(ctrl), + intWlRepo: mock_similarity_repo.NewMockIntegrityWhitelistRepo(ctrl), + fpRepo: mock_similarity_repo.NewMockFingerprintRepo(ctrl), + fpESRepo: mock_similarity_repo.NewMockFingerprintESRepo(ctrl), + scriptRepo: mock_script_repo.NewMockScriptRepo(ctrl), + codeRepo: mock_script_repo.NewMockScriptCodeRepo(ctrl), + userRepo: mock_user_repo.NewMockUserRepo(ctrl), + authSvc: mock_auth_svc.NewMockAuthSvc(ctrl), + } + similarity_repo.RegisterSimilarPair(m.pairRepo) + similarity_repo.RegisterSuspectSummary(m.suspectRepo) + similarity_repo.RegisterSimilarityWhitelist(m.whitelistRepo) + similarity_repo.RegisterIntegrityReview(m.reviewRepo) + similarity_repo.RegisterIntegrityWhitelist(m.intWlRepo) + similarity_repo.RegisterFingerprint(m.fpRepo) + similarity_repo.RegisterFingerprintES(m.fpESRepo) + script_repo.RegisterScript(m.scriptRepo) + script_repo.RegisterScriptCode(m.codeRepo) + user_repo.RegisterUser(m.userRepo) + auth_svc.RegisterAuth(m.authSvc) + return &adminSvc{}, m, context.Background() +} + +// TestAdminSvc_ListPairs_ReturnsPairsWithBriefs verifies that ListPairs +// composes pair rows with batch-loaded script + user briefs and computes +// earlier_side correctly. +func TestAdminSvc_ListPairs_ReturnsPairsWithBriefs(t *testing.T) { + svc, m, ctx := setupAdminMocks(t) + now := int64(1_700_000_000) + m.pairRepo.EXPECT().List(gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*similarity_entity.SimilarPair{ + { + ID: 1, ScriptAID: 10, ScriptBID: 20, + UserAID: 100, UserBID: 200, + Jaccard: 0.42, CommonCount: 30, + ACodeCreatedAt: now - 1000, BCodeCreatedAt: now, + Status: similarity_entity.PairStatusPending, DetectedAt: now, + }, + }, int64(1), nil) + m.scriptRepo.EXPECT().FindByIDs(gomock.Any(), gomock.Any()). + Return([]*script_entity.Script{ + {ID: 10, Name: "Alpha", UserID: 100, Public: script_entity.PublicScript}, + {ID: 20, Name: "Beta", UserID: 200, Public: script_entity.PublicScript}, + }, nil) + // FindByIDs returns a slice — the service builds the lookup map itself. + m.userRepo.EXPECT().FindByIDs(gomock.Any(), gomock.Any()). + Return([]*user_entity.User{ + {ID: 100, Username: "alice"}, + {ID: 200, Username: "bob"}, + }, nil) + // integrity_score left-join: fingerprint lookup per script, no review. + m.fpRepo.EXPECT().FindByScriptID(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil) + m.reviewRepo.EXPECT().FindByCodeID(gomock.Any(), gomock.Any()).AnyTimes().Return(nil, nil) + + resp, err := svc.ListPairs(ctx, &api.ListPairsRequest{ + PageRequest: httputils.PageRequest{Page: 1, Size: 20}, + }) + require.NoError(t, err) + assert.Equal(t, int64(1), resp.Total) + require.Len(t, resp.List, 1) + assert.Equal(t, "Alpha", resp.List[0].ScriptA.Name) + assert.Equal(t, "alice", resp.List[0].ScriptA.Username) + assert.Equal(t, "Beta", resp.List[0].ScriptB.Name) + assert.Equal(t, "bob", resp.List[0].ScriptB.Username) + assert.Equal(t, "A", resp.List[0].EarlierSide) +} + +// TestAdminSvc_GetPairDetail_PopulatesCodeAndSegments verifies that the +// detail endpoint composes pair row + script/user briefs + code bodies + +// match segments, with admin-only fields populated. +func TestAdminSvc_GetPairDetail_PopulatesCodeAndSegments(t *testing.T) { + svc, m, ctx := setupAdminMocks(t) + now := int64(1_700_000_000) + pair := &similarity_entity.SimilarPair{ + ID: 7, ScriptAID: 10, ScriptBID: 20, + UserAID: 100, UserBID: 200, + AScriptCodeID: 1000, BScriptCodeID: 2000, + Jaccard: 0.6, CommonCount: 50, + AFpCount: 100, BFpCount: 120, + ACodeCreatedAt: now - 10, BCodeCreatedAt: now, + DetectedAt: now, Status: similarity_entity.PairStatusPending, + } + m.pairRepo.EXPECT().FindByID(gomock.Any(), int64(7)).Return(pair, nil) + m.scriptRepo.EXPECT().FindByIDs(gomock.Any(), gomock.Any()).Return([]*script_entity.Script{ + {ID: 10, Name: "Alpha", UserID: 100, Public: script_entity.PublicScript}, + {ID: 20, Name: "Beta", UserID: 200, Public: script_entity.PublicScript}, + }, nil) + m.userRepo.EXPECT().FindByIDs(gomock.Any(), gomock.Any()).Return([]*user_entity.User{ + {ID: 100, Username: "alice"}, + {ID: 200, Username: "bob"}, + }, nil) + m.codeRepo.EXPECT().FindByIDIncludeDeleted(gomock.Any(), int64(1000)). + Return(&script_entity.Code{ID: 1000, Code: "code A body", Version: "1.0.0"}, nil) + m.codeRepo.EXPECT().FindByIDIncludeDeleted(gomock.Any(), int64(2000)). + Return(&script_entity.Code{ID: 2000, Code: "code B body", Version: "1.2.3"}, nil) + // BuildMatchSegments path: A-side has no fingerprint row → returns nil + // after a single lookup, no B-side call, no ES calls. + m.fpRepo.EXPECT().FindByScriptID(gomock.Any(), gomock.Any()).Times(1).Return(nil, nil) + + resp, err := svc.GetPairDetail(ctx, &api.GetPairDetailRequest{ID: 7}) + require.NoError(t, err) + require.NotNil(t, resp.Detail) + assert.Equal(t, "code A body", resp.Detail.CodeA) + assert.Equal(t, "code B body", resp.Detail.CodeB) + assert.Equal(t, "1.0.0", resp.Detail.ScriptA.Version) + assert.Equal(t, "1.2.3", resp.Detail.ScriptB.Version) + assert.Equal(t, "A", resp.Detail.EarlierSide) + require.NotNil(t, resp.Detail.AdminActions) + assert.True(t, resp.Detail.AdminActions.CanWhitelist) +} + +// TestAdminSvc_AddPairWhitelist_MarksPairAndInsertsWhitelist verifies that +// AddPairWhitelist inserts a whitelist row and transitions the pair status to +// PairStatusWhitelisted with the reviewer's uid and reason. +func TestAdminSvc_AddPairWhitelist_MarksPairAndInsertsWhitelist(t *testing.T) { + svc, m, ctx := setupAdminMocks(t) + pair := &similarity_entity.SimilarPair{ + ID: 42, ScriptAID: 10, ScriptBID: 20, + Status: similarity_entity.PairStatusPending, + } + m.pairRepo.EXPECT().FindByID(gomock.Any(), int64(42)).Return(pair, nil) + m.authSvc.U().Get(7) + m.whitelistRepo.EXPECT().Add(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, w *similarity_entity.SimilarityWhitelist) error { + assert.Equal(t, int64(10), w.ScriptAID) + assert.Equal(t, int64(20), w.ScriptBID) + assert.Equal(t, "false positive", w.Reason) + assert.Equal(t, int64(7), w.AddedBy) + assert.NotZero(t, w.Createtime) + return nil + }) + m.pairRepo.EXPECT().UpdateStatus( + gomock.Any(), int64(42), + int8(similarity_entity.PairStatusWhitelisted), + int64(7), gomock.Any(), "false positive", + ).Return(nil) + + resp, err := svc.AddPairWhitelist(ctx, &api.AddPairWhitelistRequest{ + ID: 42, + Reason: "false positive", + }) + require.NoError(t, err) + assert.NotNil(t, resp) +} + +// TestAdminSvc_ResolveIntegrityReview_UpdatesStatus verifies that +// ResolveIntegrityReview looks up the row and calls Resolve with the requested +// status and note. +func TestAdminSvc_ResolveIntegrityReview_UpdatesStatus(t *testing.T) { + svc, m, ctx := setupAdminMocks(t) + row := &similarity_entity.IntegrityReview{ + ID: 99, ScriptID: 10, ScriptCodeID: 1000, + Status: similarity_entity.ReviewStatusPending, + } + m.reviewRepo.EXPECT().FindByID(gomock.Any(), int64(99)).Return(row, nil) + m.authSvc.U().Get(5) + m.reviewRepo.EXPECT().Resolve( + gomock.Any(), int64(99), + int8(2), int64(5), gomock.Any(), "looks bad", + ).Return(nil) + + resp, err := svc.ResolveIntegrityReview(ctx, &api.ResolveIntegrityReviewRequest{ + ID: 99, + Status: 2, + Note: "looks bad", + }) + require.NoError(t, err) + assert.NotNil(t, resp) +} + +// TestAdminSvc_AddIntegrityWhitelist_Inserts verifies that +// AddIntegrityWhitelist forwards script_id + reason from the request and +// stamps added_by from the auth context. +func TestAdminSvc_AddIntegrityWhitelist_Inserts(t *testing.T) { + svc, m, ctx := setupAdminMocks(t) + m.authSvc.U().Get(3) + m.intWlRepo.EXPECT().Add(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, w *similarity_entity.IntegrityWhitelist) error { + assert.Equal(t, int64(55), w.ScriptID) + assert.Equal(t, "trusted author", w.Reason) + assert.Equal(t, int64(3), w.AddedBy) + assert.NotZero(t, w.Createtime) + return nil + }) + + resp, err := svc.AddIntegrityWhitelist(ctx, &api.AddIntegrityWhitelistRequest{ + ScriptID: 55, + Reason: "trusted author", + }) + require.NoError(t, err) + assert.NotNil(t, resp) +} + +// TestAdminSvc_ListParseFailures_DefaultFailedOnly — with no status filter the +// service asks the repo for the failed-only slice (matching the admin +// "triage" workflow where soft-deleted/too-large rows are noise). +func TestAdminSvc_ListParseFailures_DefaultFailedOnly(t *testing.T) { + svc, m, ctx := setupAdminMocks(t) + m.fpRepo.EXPECT(). + ListByParseStatus(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, filter similarity_repo.ParseFailureFilter, _ httputils.PageRequest) ([]*similarity_entity.Fingerprint, int64, error) { + require.Len(t, filter.Statuses, 1) + assert.Equal(t, similarity_entity.ParseStatusFailed, filter.Statuses[0]) + return []*similarity_entity.Fingerprint{ + { + ScriptID: 30, UserID: 100, + ParseStatus: similarity_entity.ParseStatusFailed, + ParseError: "(anonymous): Line 229:1 Illegal return statement", + ScannedAt: 1_700_000_100, + Updatetime: 1_700_000_100, + }, + }, int64(1), nil + }) + m.scriptRepo.EXPECT().FindByIDs(gomock.Any(), []int64{30}). + Return([]*script_entity.Script{ + {ID: 30, Name: "必应积分", UserID: 100, Public: script_entity.PublicScript}, + }, nil) + m.userRepo.EXPECT().FindByIDs(gomock.Any(), []int64{100}). + Return([]*user_entity.User{{ID: 100, Username: "wyz"}}, nil) + + resp, err := svc.ListParseFailures(ctx, &api.ListParseFailuresRequest{ + PageRequest: httputils.PageRequest{Page: 1, Size: 20}, + }) + require.NoError(t, err) + assert.Equal(t, int64(1), resp.Total) + require.Len(t, resp.List, 1) + item := resp.List[0] + assert.Equal(t, int64(30), item.ScriptID) + assert.Equal(t, "必应积分", item.Script.Name) + assert.Equal(t, "wyz", item.Script.Username) + assert.Equal(t, 1, item.ParseStatus) + assert.Contains(t, item.ParseError, "Illegal return statement") +} + +// TestAdminSvc_ListParseFailures_ExplicitSkipStatus — passing status=2 asks the +// repo for the skip slice only. +func TestAdminSvc_ListParseFailures_ExplicitSkipStatus(t *testing.T) { + svc, m, ctx := setupAdminMocks(t) + skipStatus := 2 + m.fpRepo.EXPECT(). + ListByParseStatus(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, filter similarity_repo.ParseFailureFilter, _ httputils.PageRequest) ([]*similarity_entity.Fingerprint, int64, error) { + require.Len(t, filter.Statuses, 1) + assert.Equal(t, similarity_entity.ParseStatusSkip, filter.Statuses[0]) + return nil, 0, nil + }) + m.scriptRepo.EXPECT().FindByIDs(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + m.userRepo.EXPECT().FindByIDs(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + + resp, err := svc.ListParseFailures(ctx, &api.ListParseFailuresRequest{ + PageRequest: httputils.PageRequest{Page: 1, Size: 20}, + Status: &skipStatus, + }) + require.NoError(t, err) + assert.Equal(t, int64(0), resp.Total) + assert.Empty(t, resp.List) +} diff --git a/internal/service/similarity_svc/admin_whitelist.go b/internal/service/similarity_svc/admin_whitelist.go new file mode 100644 index 0000000..cc4d641 --- /dev/null +++ b/internal/service/similarity_svc/admin_whitelist.go @@ -0,0 +1,189 @@ +package similarity_svc + +import ( + "context" + "net/http" + "time" + + "github.com/cago-frame/cago/pkg/i18n" + "github.com/cago-frame/cago/pkg/utils/httputils" + api "github.com/scriptscat/scriptlist/internal/api/similarity" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + "github.com/scriptscat/scriptlist/internal/pkg/code" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + "github.com/scriptscat/scriptlist/internal/service/auth_svc" +) + +func (s *adminSvc) AddPairWhitelist(ctx context.Context, req *api.AddPairWhitelistRequest) (*api.AddPairWhitelistResponse, error) { + pair, err := similarity_repo.SimilarPair().FindByID(ctx, req.ID) + if err != nil { + return nil, err + } + if pair == nil { + return nil, i18n.NewError(ctx, code.SimilarityPairNotFound) + } + authInfo := auth_svc.Auth().Get(ctx) + if authInfo == nil { + return nil, i18n.NewErrorWithStatus(ctx, http.StatusUnauthorized, code.SimilarityAccessDenied) + } + now := time.Now().Unix() + if err := similarity_repo.SimilarityWhitelist().Add(ctx, &similarity_entity.SimilarityWhitelist{ + ScriptAID: pair.ScriptAID, + ScriptBID: pair.ScriptBID, + Reason: req.Reason, + AddedBy: authInfo.UserID, + Createtime: now, + }); err != nil { + return nil, err + } + if err := similarity_repo.SimilarPair().UpdateStatus(ctx, pair.ID, + int8(similarity_entity.PairStatusWhitelisted), authInfo.UserID, now, req.Reason); err != nil { + return nil, err + } + return &api.AddPairWhitelistResponse{}, nil +} + +func (s *adminSvc) RemovePairWhitelist(ctx context.Context, req *api.RemovePairWhitelistRequest) (*api.RemovePairWhitelistResponse, error) { + pair, err := similarity_repo.SimilarPair().FindByID(ctx, req.ID) + if err != nil { + return nil, err + } + if pair == nil { + return nil, i18n.NewError(ctx, code.SimilarityPairNotFound) + } + if err := similarity_repo.SimilarityWhitelist().Remove(ctx, pair.ScriptAID, pair.ScriptBID); err != nil { + return nil, err + } + authInfo := auth_svc.Auth().Get(ctx) + uid := int64(0) + if authInfo != nil { + uid = authInfo.UserID + } + if err := similarity_repo.SimilarPair().UpdateStatus(ctx, pair.ID, + int8(similarity_entity.PairStatusPending), uid, time.Now().Unix(), ""); err != nil { + return nil, err + } + return &api.RemovePairWhitelistResponse{}, nil +} + +// RemovePairWhitelistByID removes a whitelist row by its own ID (used from the +// standalone whitelist list page where the caller doesn't have the pair ID). +// Unlike RemovePairWhitelist, this does NOT touch any similar_pair row status — +// the whitelist row alone is the source of truth, and any future scan will +// re-emit the pair if it's still similar. +func (s *adminSvc) RemovePairWhitelistByID(ctx context.Context, req *api.RemovePairWhitelistByIDRequest) (*api.RemovePairWhitelistByIDResponse, error) { + row, err := similarity_repo.SimilarityWhitelist().FindByID(ctx, req.ID) + if err != nil { + return nil, err + } + if row == nil { + return nil, i18n.NewError(ctx, code.SimilarityPairNotFound) + } + if err := similarity_repo.SimilarityWhitelist().Remove(ctx, row.ScriptAID, row.ScriptBID); err != nil { + return nil, err + } + return &api.RemovePairWhitelistByIDResponse{}, nil +} + +func (s *adminSvc) ListPairWhitelist(ctx context.Context, req *api.ListPairWhitelistRequest) (*api.ListPairWhitelistResponse, error) { + rows, total, err := similarity_repo.SimilarityWhitelist().List(ctx, req.PageRequest) + if err != nil { + return nil, err + } + scriptIDs := make([]int64, 0, 2*len(rows)) + userIDs := make([]int64, 0, len(rows)) + for _, w := range rows { + scriptIDs = append(scriptIDs, w.ScriptAID, w.ScriptBID) + userIDs = append(userIDs, w.AddedBy) + } + scripts, err := fetchScriptMap(ctx, scriptIDs) + if err != nil { + return nil, err + } + users, err := fetchUserMap(ctx, userIDs) + if err != nil { + return nil, err + } + items := make([]*api.PairWhitelistItem, 0, len(rows)) + for _, w := range rows { + addedByName := "" + if u, ok := users[w.AddedBy]; ok && u != nil { + addedByName = u.Username + } + items = append(items, &api.PairWhitelistItem{ + ID: w.ID, + ScriptA: buildScriptBrief(scripts[w.ScriptAID], users), + ScriptB: buildScriptBrief(scripts[w.ScriptBID], users), + Reason: w.Reason, + AddedBy: w.AddedBy, + AddedByName: addedByName, + Createtime: w.Createtime, + }) + } + return &api.ListPairWhitelistResponse{ + PageResponse: httputils.PageResponse[*api.PairWhitelistItem]{List: items, Total: total}, + }, nil +} + +func (s *adminSvc) ListIntegrityWhitelist(ctx context.Context, req *api.ListIntegrityWhitelistRequest) (*api.ListIntegrityWhitelistResponse, error) { + rows, total, err := similarity_repo.IntegrityWhitelist().List(ctx, req.PageRequest) + if err != nil { + return nil, err + } + scriptIDs := make([]int64, 0, len(rows)) + userIDs := make([]int64, 0, len(rows)) + for _, w := range rows { + scriptIDs = append(scriptIDs, w.ScriptID) + userIDs = append(userIDs, w.AddedBy) + } + scripts, err := fetchScriptMap(ctx, scriptIDs) + if err != nil { + return nil, err + } + users, err := fetchUserMap(ctx, userIDs) + if err != nil { + return nil, err + } + items := make([]*api.IntegrityWhitelistItem, 0, len(rows)) + for _, w := range rows { + name := "" + if u, ok := users[w.AddedBy]; ok && u != nil { + name = u.Username + } + items = append(items, &api.IntegrityWhitelistItem{ + ID: w.ID, + Script: buildScriptBrief(scripts[w.ScriptID], users), + Reason: w.Reason, + AddedBy: w.AddedBy, + AddedByName: name, + Createtime: w.Createtime, + }) + } + return &api.ListIntegrityWhitelistResponse{ + PageResponse: httputils.PageResponse[*api.IntegrityWhitelistItem]{List: items, Total: total}, + }, nil +} + +func (s *adminSvc) AddIntegrityWhitelist(ctx context.Context, req *api.AddIntegrityWhitelistRequest) (*api.AddIntegrityWhitelistResponse, error) { + authInfo := auth_svc.Auth().Get(ctx) + uid := int64(0) + if authInfo != nil { + uid = authInfo.UserID + } + if err := similarity_repo.IntegrityWhitelist().Add(ctx, &similarity_entity.IntegrityWhitelist{ + ScriptID: req.ScriptID, + Reason: req.Reason, + AddedBy: uid, + Createtime: time.Now().Unix(), + }); err != nil { + return nil, err + } + return &api.AddIntegrityWhitelistResponse{}, nil +} + +func (s *adminSvc) RemoveIntegrityWhitelist(ctx context.Context, req *api.RemoveIntegrityWhitelistRequest) (*api.RemoveIntegrityWhitelistResponse, error) { + if err := similarity_repo.IntegrityWhitelist().Remove(ctx, req.ScriptID); err != nil { + return nil, err + } + return &api.RemoveIntegrityWhitelistResponse{}, nil +} diff --git a/internal/service/similarity_svc/backfill_state.go b/internal/service/similarity_svc/backfill_state.go new file mode 100644 index 0000000..a81fa3b --- /dev/null +++ b/internal/service/similarity_svc/backfill_state.go @@ -0,0 +1,115 @@ +package similarity_svc + +import ( + "context" + "strconv" + + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" +) + +// Persistent keys for backfill state (§4.5 + §6.1). Cursor + progress metadata +// live in `pre_system_config` so they survive process restarts and are shared +// across pods. The running flag itself is held in Redis via SETNX for atomic +// CAS acquisition per spec §2.3 — DB read-then-write would race when two +// admins click "start backfill" within a few ms of each other. +const ( + BackfillCursorKey = "similarity.backfill_cursor" + BackfillStartedAtKey = "similarity.backfill_started_at" + BackfillFinishedAtKey = "similarity.backfill_finished_at" + BackfillTotalTargetKey = "similarity.backfill_total" +) + +// BackfillState is a point-in-time snapshot returned by GetBackfillStatus. +type BackfillState struct { + Running bool `json:"running"` + Cursor int64 `json:"cursor"` + Total int64 `json:"total"` + StartedAt int64 `json:"started_at"` + FinishedAt int64 `json:"finished_at"` +} + +// LoadBackfillState reads the full backfill status. The running flag is +// sourced from the Redis lock (single source of truth, §2.3); cursor + metadata +// come from system_config so they survive Redis restarts. +func LoadBackfillState(ctx context.Context) (*BackfillState, error) { + repo := similarity_repo.BackfillState() + running, err := repo.CheckLock(ctx) + if err != nil { + return nil, err + } + cursor, err := repo.ReadInt64Config(ctx, BackfillCursorKey) + if err != nil { + return nil, err + } + total, err := repo.ReadInt64Config(ctx, BackfillTotalTargetKey) + if err != nil { + return nil, err + } + started, err := repo.ReadInt64Config(ctx, BackfillStartedAtKey) + if err != nil { + return nil, err + } + finished, err := repo.ReadInt64Config(ctx, BackfillFinishedAtKey) + if err != nil { + return nil, err + } + return &BackfillState{ + Running: running, + Cursor: cursor, + Total: total, + StartedAt: started, + FinishedAt: finished, + }, nil +} + +// TryAcquireBackfillLock atomically claims the backfill slot via Redis SETNX +// (spec §2.3). Returns false if another worker already holds the lock — +// two admins clicking start at roughly the same time only ever succeed once. +// On successful acquisition the metadata rows (started_at / total / finished_at) +// are written to system_config for the status endpoint; metadata write +// failures release the Redis lock so a half-started state never persists. +func TryAcquireBackfillLock(ctx context.Context, total int64, startedAt int64) (bool, error) { + repo := similarity_repo.BackfillState() + ok, err := repo.AcquireLock(ctx) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + if err := repo.WriteConfig(ctx, BackfillStartedAtKey, strconv.FormatInt(startedAt, 10)); err != nil { + _ = repo.ReleaseLock(ctx) + return false, err + } + if err := repo.WriteConfig(ctx, BackfillTotalTargetKey, strconv.FormatInt(total, 10)); err != nil { + _ = repo.ReleaseLock(ctx) + return false, err + } + if err := repo.WriteConfig(ctx, BackfillFinishedAtKey, "0"); err != nil { + _ = repo.ReleaseLock(ctx) + return false, err + } + return true, nil +} + +// SetBackfillCursor advances the cursor so a crashed run can resume. +func SetBackfillCursor(ctx context.Context, cursor int64) error { + return similarity_repo.BackfillState().WriteConfig(ctx, BackfillCursorKey, strconv.FormatInt(cursor, 10)) +} + +// FinishBackfill releases the Redis running lock and records the finish +// timestamp in system_config. Callers should invoke this in a defer so an +// unexpected error or panic does not leave the lock held — even if it does, +// the 2h TTL will eventually expire the key. +func FinishBackfill(ctx context.Context, finishedAt int64) error { + repo := similarity_repo.BackfillState() + if err := repo.ReleaseLock(ctx); err != nil { + return err + } + return repo.WriteConfig(ctx, BackfillFinishedAtKey, strconv.FormatInt(finishedAt, 10)) +} + +// ResetBackfillCursor is called when the admin requests a fresh full run. +func ResetBackfillCursor(ctx context.Context) error { + return similarity_repo.BackfillState().WriteConfig(ctx, BackfillCursorKey, "0") +} diff --git a/internal/service/similarity_svc/backfill_state_test.go b/internal/service/similarity_svc/backfill_state_test.go new file mode 100644 index 0000000..b20b05a --- /dev/null +++ b/internal/service/similarity_svc/backfill_state_test.go @@ -0,0 +1,163 @@ +package similarity_svc + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + mock_similarity_repo "github.com/scriptscat/scriptlist/internal/repository/similarity_repo/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +// backfillStateMu serializes access to the similarity_repo service-locator so +// these tests don't race each other or other suites. +var backfillStateMu sync.Mutex + +func setupBackfillStateMocks(t *testing.T) (*mock_similarity_repo.MockBackfillStateRepo, context.Context) { + backfillStateMu.Lock() + t.Cleanup(backfillStateMu.Unlock) + ctrl := gomock.NewController(t) + m := mock_similarity_repo.NewMockBackfillStateRepo(ctrl) + similarity_repo.RegisterBackfillState(m) + return m, context.Background() +} + +// TestLoadBackfillState_AllMissing verifies the zero-value default when no +// system_config rows exist yet and the Redis lock isn't held — natural state +// on first boot before any backfill has been triggered. +func TestLoadBackfillState_AllMissing(t *testing.T) { + m, ctx := setupBackfillStateMocks(t) + m.EXPECT().CheckLock(gomock.Any()).Return(false, nil) + m.EXPECT().ReadInt64Config(gomock.Any(), gomock.Any()).Return(int64(0), nil).Times(4) + + state, err := LoadBackfillState(ctx) + require.NoError(t, err) + assert.False(t, state.Running) + assert.Equal(t, int64(0), state.Cursor) + assert.Equal(t, int64(0), state.Total) + assert.Equal(t, int64(0), state.StartedAt) + assert.Equal(t, int64(0), state.FinishedAt) +} + +// TestLoadBackfillState_PopulatedRows — running flag sourced from the Redis +// lock, everything else from system_config. +func TestLoadBackfillState_PopulatedRows(t *testing.T) { + m, ctx := setupBackfillStateMocks(t) + m.EXPECT().CheckLock(gomock.Any()).Return(true, nil) + m.EXPECT().ReadInt64Config(gomock.Any(), BackfillCursorKey).Return(int64(1234), nil) + m.EXPECT().ReadInt64Config(gomock.Any(), BackfillTotalTargetKey).Return(int64(9999), nil) + m.EXPECT().ReadInt64Config(gomock.Any(), BackfillStartedAtKey).Return(int64(1700000000), nil) + m.EXPECT().ReadInt64Config(gomock.Any(), BackfillFinishedAtKey).Return(int64(1700003600), nil) + + state, err := LoadBackfillState(ctx) + require.NoError(t, err) + assert.True(t, state.Running) + assert.Equal(t, int64(1234), state.Cursor) + assert.Equal(t, int64(9999), state.Total) + assert.Equal(t, int64(1700000000), state.StartedAt) + assert.Equal(t, int64(1700003600), state.FinishedAt) +} + +// TestLoadBackfillState_MalformedInt — non-numeric cursor coerces to 0 +// (handled inside the repo implementation). +func TestLoadBackfillState_MalformedInt(t *testing.T) { + m, ctx := setupBackfillStateMocks(t) + m.EXPECT().CheckLock(gomock.Any()).Return(false, nil) + m.EXPECT().ReadInt64Config(gomock.Any(), BackfillCursorKey).Return(int64(0), nil) + m.EXPECT().ReadInt64Config(gomock.Any(), BackfillTotalTargetKey).Return(int64(0), nil) + m.EXPECT().ReadInt64Config(gomock.Any(), BackfillStartedAtKey).Return(int64(0), nil) + m.EXPECT().ReadInt64Config(gomock.Any(), BackfillFinishedAtKey).Return(int64(0), nil) + + state, err := LoadBackfillState(ctx) + require.NoError(t, err) + assert.Equal(t, int64(0), state.Cursor) +} + +// TestLoadBackfillState_RedisCheckError bubbles up Redis errors instead of +// silently reporting running=false. +func TestLoadBackfillState_RedisCheckError(t *testing.T) { + m, ctx := setupBackfillStateMocks(t) + m.EXPECT().CheckLock(gomock.Any()).Return(false, errors.New("redis down")) + + _, err := LoadBackfillState(ctx) + assert.Error(t, err) +} + +// TestTryAcquireBackfillLock_Success covers the happy path: idle Redis → +// SETNX succeeds, metadata rows get written, returns acquired=true. +func TestTryAcquireBackfillLock_Success(t *testing.T) { + m, ctx := setupBackfillStateMocks(t) + m.EXPECT().AcquireLock(gomock.Any()).Return(true, nil) + m.EXPECT().WriteConfig(gomock.Any(), BackfillStartedAtKey, "1700000000").Return(nil) + m.EXPECT().WriteConfig(gomock.Any(), BackfillTotalTargetKey, "500").Return(nil) + m.EXPECT().WriteConfig(gomock.Any(), BackfillFinishedAtKey, "0").Return(nil) + + acquired, err := TryAcquireBackfillLock(ctx, 500, 1700000000) + require.NoError(t, err) + assert.True(t, acquired) +} + +// TestTryAcquireBackfillLock_AlreadyRunning — two admins click start at the +// same time, Redis SETNX only lets one through and the second returns false +// with no DB writes. +func TestTryAcquireBackfillLock_AlreadyRunning(t *testing.T) { + m, ctx := setupBackfillStateMocks(t) + m.EXPECT().AcquireLock(gomock.Any()).Return(false, nil) + // No WriteConfig calls expected — mock will fail the test if one happens. + + acquired, err := TryAcquireBackfillLock(ctx, 500, 1700000000) + require.NoError(t, err) + assert.False(t, acquired) +} + +// TestTryAcquireBackfillLock_RedisError bubbles up Redis SETNX errors. +func TestTryAcquireBackfillLock_RedisError(t *testing.T) { + m, ctx := setupBackfillStateMocks(t) + m.EXPECT().AcquireLock(gomock.Any()).Return(false, errors.New("redis down")) + + acquired, err := TryAcquireBackfillLock(ctx, 500, 1700000000) + assert.Error(t, err) + assert.False(t, acquired) +} + +// TestTryAcquireBackfillLock_MetadataWriteFails_ReleasesLock — if the +// metadata write fails after Redis has been locked, we must release the lock +// so a retry has a chance (instead of waiting for the 2h TTL). +func TestTryAcquireBackfillLock_MetadataWriteFails_ReleasesLock(t *testing.T) { + m, ctx := setupBackfillStateMocks(t) + m.EXPECT().AcquireLock(gomock.Any()).Return(true, nil) + m.EXPECT().WriteConfig(gomock.Any(), BackfillStartedAtKey, gomock.Any()).Return(errors.New("db down")) + m.EXPECT().ReleaseLock(gomock.Any()).Return(nil) + + acquired, err := TryAcquireBackfillLock(ctx, 500, 1700000000) + assert.Error(t, err) + assert.False(t, acquired) +} + +func TestSetBackfillCursor_WritesInt64(t *testing.T) { + m, ctx := setupBackfillStateMocks(t) + m.EXPECT().WriteConfig(gomock.Any(), BackfillCursorKey, "42").Return(nil) + + assert.NoError(t, SetBackfillCursor(ctx, 42)) +} + +// TestFinishBackfill_ReleasesRedisAndStampsFinishedAt ensures the deferred +// release path drops the Redis lock AND writes the finish timestamp. +func TestFinishBackfill_ReleasesRedisAndStampsFinishedAt(t *testing.T) { + m, ctx := setupBackfillStateMocks(t) + m.EXPECT().ReleaseLock(gomock.Any()).Return(nil) + m.EXPECT().WriteConfig(gomock.Any(), BackfillFinishedAtKey, "1700000999").Return(nil) + + assert.NoError(t, FinishBackfill(ctx, 1700000999)) +} + +func TestResetBackfillCursor_WritesZero(t *testing.T) { + m, ctx := setupBackfillStateMocks(t) + m.EXPECT().WriteConfig(gomock.Any(), BackfillCursorKey, "0").Return(nil) + + assert.NoError(t, ResetBackfillCursor(ctx)) +} diff --git a/internal/service/similarity_svc/doc.go b/internal/service/similarity_svc/doc.go new file mode 100644 index 0000000..bc9aac8 --- /dev/null +++ b/internal/service/similarity_svc/doc.go @@ -0,0 +1,9 @@ +// Package similarity_svc implements code similarity detection for ScriptList. +// +// Phase 1 (this package as initially shipped) provides a pure-function +// fingerprint extraction algorithm based on winnowing over AST-normalized +// token streams. Subsequent phases layer on persistent storage, Elasticsearch +// indexing, NSQ-driven scanning, and integrity pre-checks. +// +// See docs/superpowers/specs/2026-04-13-code-similarity-detection-design.md. +package similarity_svc diff --git a/internal/service/similarity_svc/fingerprint.go b/internal/service/similarity_svc/fingerprint.go new file mode 100644 index 0000000..945ea05 --- /dev/null +++ b/internal/service/similarity_svc/fingerprint.go @@ -0,0 +1,917 @@ +package similarity_svc + +import ( + "github.com/cespare/xxhash/v2" + "github.com/dop251/goja/ast" + "github.com/dop251/goja/parser" +) + +// TokenKind is the category of a normalized token. +type TokenKind int + +const ( + KindUnknown TokenKind = iota + KindVar // any identifier (variable/function/parameter name) + KindStr // string literal + KindNum // numeric literal + KindBool // boolean literal + KindNull // null literal + KindKeyword // reserved words: function, if, for, return, ... + KindPunct // punctuation: ( ) { } [ ] , ; . + KindOp // operator: + - * / = == != < > && || ... +) + +// String returns the human-readable name of the kind, used in debug and tests. +func (k TokenKind) String() string { + switch k { + case KindVar: + return "VAR" + case KindStr: + return "STR" + case KindNum: + return "NUM" + case KindBool: + return "BOOL" + case KindNull: + return "NULL" + case KindKeyword: + return "KW" + case KindPunct: + return "PUNCT" + case KindOp: + return "OP" + } + return "UNK" +} + +// Token is a single normalized token in the derived token stream. +type Token struct { + Kind TokenKind + Value string // populated only for KW/OP/PUNCT (e.g., "function", "+"); empty for placeholders + Position int // byte offset in the original source +} + +// FingerprintEntry is a single (hash, position) output of the winnowing step. +type FingerprintEntry struct { + Hash uint64 + Position int // byte offset of the k-gram's starting token in the original source +} + +// FingerprintResult is the output of ExtractFingerprints. +type FingerprintResult struct { + Fingerprints []FingerprintEntry + TotalTokens int + // ParseError is non-nil iff parseAndNormalize failed (i.e., syntactically + // invalid JS). When ExtractFingerprints returns a non-nil error, it equals + // this field — the dual-channel reporting lets batch callers stash failures + // in the result while single-call sites can still use the standard + // `if err != nil` pattern. + ParseError error +} + +// FingerprintOptions tunes the algorithm. Zero-valued options fall back to DefaultOptions. +type FingerprintOptions struct { + KGramSize int + WinnowingWindow int +} + +// DefaultOptions returns the parameters specified in the spec (k=5, w=10). +func DefaultOptions() FingerprintOptions { + return FingerprintOptions{KGramSize: 5, WinnowingWindow: 10} +} + +// parseAndNormalize parses JavaScript source and walks the AST to produce a +// normalized token stream. Identifiers collapse to KindVar, literals to +// KindStr/KindNum/KindBool/KindNull, and structural constructs emit KindKeyword +// or KindPunct tokens so that the token stream still encodes program structure. +// +// Returns an error if the source is not parseable as valid JavaScript. +// asyncWrapperPrefix / asyncWrapperSuffix mirror how ScriptCat wraps +// background and cron scripts at runtime — inside an async function body. This +// lets us accept sources that use top-level `return` or `await`, which are +// legal in the runtime but illegal as a standalone ECMAScript Script. +const asyncWrapperPrefix = "(async function(){\n" +const asyncWrapperSuffix = "\n})" + +func parseAndNormalize(code string) ([]Token, error) { + prog, err := parser.ParseFile(nil, "", code, 0) + if err == nil { + var tokens []Token + for _, stmt := range prog.Body { + walkNode(stmt, &tokens) + } + return tokens, nil + } + + // Parse failed — retry wrapped. Top-level return/await is the common + // cause (background/cron scripts). On success, token positions need to be + // shifted back by the wrapper prefix length so downstream match segments + // still point at the original source. + wrapped := asyncWrapperPrefix + code + asyncWrapperSuffix + prog2, err2 := parser.ParseFile(nil, "", wrapped, 0) + if err2 != nil { + // Wrapper didn't help — return the original error, it's the most + // informative one for the user. + return nil, err + } + var tokens []Token + for _, stmt := range prog2.Body { + walkNode(stmt, &tokens) + } + offset := len(asyncWrapperPrefix) + maxPos := max(len(code)-1, 0) + for i := range tokens { + p := max(tokens[i].Position-offset, 0) + p = min(p, maxPos) + tokens[i].Position = p + } + return tokens, nil +} + +// nodePos returns the 0-indexed byte offset of a node in the source. +// goja uses 1-based file.Idx; subtract 1 to get a byte offset. +func nodePos(n ast.Node) int { + if n == nil { + return 0 + } + p := int(n.Idx0()) - 1 + if p < 0 { + return 0 + } + return p +} + +// walkNode traverses an AST node in pre-order, appending normalized tokens to out. +// Unknown node types emit a KindUnknown marker so novel constructs still contribute +// to the token stream deterministically instead of being silently dropped. +// +// Coverage spans ES2015+ (classes, arrow functions, let/const, destructuring, +// template literals, async/await, spread, optional chaining, etc.). Userscripts +// today are almost entirely modern JS — earlier versions of this walker dropped +// anything inside a top-level `class` declaration, producing < 14 tokens for +// 1000+ line files and tripping the `too_few_fingerprints` skip in scan. +func walkNode(node ast.Node, out *[]Token) { + if node == nil { + return + } + pos := nodePos(node) + + switch n := node.(type) { + case *ast.Identifier: + *out = append(*out, Token{Kind: KindVar, Position: pos}) + + case *ast.PrivateIdentifier: + *out = append(*out, Token{Kind: KindVar, Position: pos}) + + case *ast.StringLiteral: + *out = append(*out, Token{Kind: KindStr, Position: pos}) + + case *ast.NumberLiteral: + *out = append(*out, Token{Kind: KindNum, Position: pos}) + + case *ast.BooleanLiteral: + *out = append(*out, Token{Kind: KindBool, Position: pos}) + + case *ast.NullLiteral: + *out = append(*out, Token{Kind: KindNull, Position: pos}) + + case *ast.RegExpLiteral: + *out = append(*out, Token{Kind: KindStr, Position: pos}) + + case *ast.TemplateLiteral: + *out = append(*out, Token{Kind: KindStr, Position: pos}) + if n.Tag != nil { + walkNode(n.Tag, out) + } + for _, expr := range n.Expressions { + walkNode(expr, out) + } + + case *ast.ThisExpression: + *out = append(*out, Token{Kind: KindKeyword, Value: "this", Position: pos}) + + case *ast.SuperExpression: + *out = append(*out, Token{Kind: KindKeyword, Value: "super", Position: pos}) + + case *ast.MetaProperty: + *out = append(*out, Token{Kind: KindKeyword, Value: "meta", Position: pos}) + + case *ast.FunctionDeclaration: + *out = append(*out, Token{Kind: KindKeyword, Value: "function", Position: pos}) + if n.Function != nil { + walkNode(n.Function, out) + } + + case *ast.FunctionLiteral: + if n.Name != nil { + walkNode(n.Name, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: "(", Position: pos}) + if n.ParameterList != nil { + for _, p := range n.ParameterList.List { + walkNode(p, out) + } + if n.ParameterList.Rest != nil { + *out = append(*out, Token{Kind: KindOp, Value: "...", Position: pos}) + walkNode(n.ParameterList.Rest, out) + } + } + *out = append(*out, Token{Kind: KindPunct, Value: ")", Position: pos}) + if n.Body != nil { + walkNode(n.Body, out) + } + + case *ast.ArrowFunctionLiteral: + *out = append(*out, Token{Kind: KindPunct, Value: "(", Position: pos}) + if n.ParameterList != nil { + for _, p := range n.ParameterList.List { + walkNode(p, out) + } + if n.ParameterList.Rest != nil { + *out = append(*out, Token{Kind: KindOp, Value: "...", Position: pos}) + walkNode(n.ParameterList.Rest, out) + } + } + *out = append(*out, Token{Kind: KindPunct, Value: ")", Position: pos}) + *out = append(*out, Token{Kind: KindOp, Value: "=>", Position: pos}) + if n.Body != nil { + walkNode(n.Body, out) + } + + case *ast.ExpressionBody: + if n.Expression != nil { + walkNode(n.Expression, out) + } + + case *ast.ClassDeclaration: + *out = append(*out, Token{Kind: KindKeyword, Value: "class", Position: pos}) + if n.Class != nil { + walkNode(n.Class, out) + } + + case *ast.ClassLiteral: + if n.Name != nil { + walkNode(n.Name, out) + } + if n.SuperClass != nil { + *out = append(*out, Token{Kind: KindKeyword, Value: "extends", Position: pos}) + walkNode(n.SuperClass, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: "{", Position: pos}) + for _, el := range n.Body { + walkNode(el, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: "}", Position: pos}) + + case *ast.MethodDefinition: + if n.Static { + *out = append(*out, Token{Kind: KindKeyword, Value: "static", Position: pos}) + } + if n.Key != nil { + walkNode(n.Key, out) + } + if n.Body != nil { + walkNode(n.Body, out) + } + + case *ast.FieldDefinition: + if n.Static { + *out = append(*out, Token{Kind: KindKeyword, Value: "static", Position: pos}) + } + if n.Key != nil { + walkNode(n.Key, out) + } + if n.Initializer != nil { + *out = append(*out, Token{Kind: KindOp, Value: "=", Position: pos}) + walkNode(n.Initializer, out) + } + + case *ast.ClassStaticBlock: + *out = append(*out, Token{Kind: KindKeyword, Value: "static", Position: pos}) + if n.Block != nil { + walkNode(n.Block, out) + } + + case *ast.BlockStatement: + *out = append(*out, Token{Kind: KindPunct, Value: "{", Position: pos}) + for _, s := range n.List { + walkNode(s, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: "}", Position: pos}) + + case *ast.EmptyStatement: + *out = append(*out, Token{Kind: KindPunct, Value: ";", Position: pos}) + + case *ast.DebuggerStatement: + *out = append(*out, Token{Kind: KindKeyword, Value: "debugger", Position: pos}) + + case *ast.ReturnStatement: + *out = append(*out, Token{Kind: KindKeyword, Value: "return", Position: pos}) + if n.Argument != nil { + walkNode(n.Argument, out) + } + + case *ast.ThrowStatement: + *out = append(*out, Token{Kind: KindKeyword, Value: "throw", Position: pos}) + if n.Argument != nil { + walkNode(n.Argument, out) + } + + case *ast.TryStatement: + *out = append(*out, Token{Kind: KindKeyword, Value: "try", Position: pos}) + if n.Body != nil { + walkNode(n.Body, out) + } + if n.Catch != nil { + walkNode(n.Catch, out) + } + if n.Finally != nil { + *out = append(*out, Token{Kind: KindKeyword, Value: "finally", Position: pos}) + walkNode(n.Finally, out) + } + + case *ast.CatchStatement: + *out = append(*out, Token{Kind: KindKeyword, Value: "catch", Position: pos}) + if n.Parameter != nil { + walkNode(n.Parameter, out) + } + if n.Body != nil { + walkNode(n.Body, out) + } + + case *ast.SwitchStatement: + *out = append(*out, Token{Kind: KindKeyword, Value: "switch", Position: pos}) + if n.Discriminant != nil { + walkNode(n.Discriminant, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: "{", Position: pos}) + for _, c := range n.Body { + walkNode(c, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: "}", Position: pos}) + + case *ast.CaseStatement: + if n.Test == nil { + *out = append(*out, Token{Kind: KindKeyword, Value: "default", Position: pos}) + } else { + *out = append(*out, Token{Kind: KindKeyword, Value: "case", Position: pos}) + walkNode(n.Test, out) + } + for _, s := range n.Consequent { + walkNode(s, out) + } + + case *ast.BranchStatement: + *out = append(*out, Token{Kind: KindKeyword, Value: n.Token.String(), Position: pos}) + if n.Label != nil { + walkNode(n.Label, out) + } + + case *ast.LabelledStatement: + if n.Label != nil { + walkNode(n.Label, out) + } + if n.Statement != nil { + walkNode(n.Statement, out) + } + + case *ast.VariableStatement: + *out = append(*out, Token{Kind: KindKeyword, Value: "var", Position: pos}) + for _, b := range n.List { + walkNode(b, out) + } + + case *ast.VariableDeclaration: + *out = append(*out, Token{Kind: KindKeyword, Value: "var", Position: pos}) + for _, b := range n.List { + walkNode(b, out) + } + + case *ast.LexicalDeclaration: + *out = append(*out, Token{Kind: KindKeyword, Value: n.Token.String(), Position: pos}) + for _, b := range n.List { + walkNode(b, out) + } + + case *ast.Binding: + if n.Target != nil { + walkNode(n.Target, out) + } + if n.Initializer != nil { + *out = append(*out, Token{Kind: KindOp, Value: "=", Position: pos}) + walkNode(n.Initializer, out) + } + + case *ast.ArrayPattern: + *out = append(*out, Token{Kind: KindPunct, Value: "[", Position: pos}) + for _, e := range n.Elements { + walkNode(e, out) + } + if n.Rest != nil { + *out = append(*out, Token{Kind: KindOp, Value: "...", Position: pos}) + walkNode(n.Rest, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: "]", Position: pos}) + + case *ast.ObjectPattern: + *out = append(*out, Token{Kind: KindPunct, Value: "{", Position: pos}) + for _, p := range n.Properties { + walkNode(p, out) + } + if n.Rest != nil { + *out = append(*out, Token{Kind: KindOp, Value: "...", Position: pos}) + walkNode(n.Rest, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: "}", Position: pos}) + + case *ast.BinaryExpression: + if n.Left != nil { + walkNode(n.Left, out) + } + *out = append(*out, Token{Kind: KindOp, Value: n.Operator.String(), Position: pos}) + if n.Right != nil { + walkNode(n.Right, out) + } + + case *ast.AssignExpression: + if n.Left != nil { + walkNode(n.Left, out) + } + *out = append(*out, Token{Kind: KindOp, Value: n.Operator.String(), Position: pos}) + if n.Right != nil { + walkNode(n.Right, out) + } + + case *ast.UnaryExpression: + *out = append(*out, Token{Kind: KindOp, Value: n.Operator.String(), Position: pos}) + if n.Operand != nil { + walkNode(n.Operand, out) + } + + case *ast.ConditionalExpression: + if n.Test != nil { + walkNode(n.Test, out) + } + *out = append(*out, Token{Kind: KindOp, Value: "?", Position: pos}) + if n.Consequent != nil { + walkNode(n.Consequent, out) + } + *out = append(*out, Token{Kind: KindOp, Value: ":", Position: pos}) + if n.Alternate != nil { + walkNode(n.Alternate, out) + } + + case *ast.SequenceExpression: + for _, e := range n.Sequence { + walkNode(e, out) + } + + case *ast.AwaitExpression: + *out = append(*out, Token{Kind: KindKeyword, Value: "await", Position: pos}) + if n.Argument != nil { + walkNode(n.Argument, out) + } + + case *ast.YieldExpression: + *out = append(*out, Token{Kind: KindKeyword, Value: "yield", Position: pos}) + if n.Argument != nil { + walkNode(n.Argument, out) + } + + case *ast.SpreadElement: + *out = append(*out, Token{Kind: KindOp, Value: "...", Position: pos}) + if n.Expression != nil { + walkNode(n.Expression, out) + } + + case *ast.OptionalChain: + if n.Expression != nil { + walkNode(n.Expression, out) + } + + case *ast.Optional: + *out = append(*out, Token{Kind: KindOp, Value: "?.", Position: pos}) + if n.Expression != nil { + walkNode(n.Expression, out) + } + + case *ast.NewExpression: + *out = append(*out, Token{Kind: KindKeyword, Value: "new", Position: pos}) + if n.Callee != nil { + walkNode(n.Callee, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: "(", Position: pos}) + for _, arg := range n.ArgumentList { + walkNode(arg, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: ")", Position: pos}) + + case *ast.IfStatement: + *out = append(*out, Token{Kind: KindKeyword, Value: "if", Position: pos}) + if n.Test != nil { + walkNode(n.Test, out) + } + if n.Consequent != nil { + walkNode(n.Consequent, out) + } + if n.Alternate != nil { + *out = append(*out, Token{Kind: KindKeyword, Value: "else", Position: pos}) + walkNode(n.Alternate, out) + } + + case *ast.ForStatement: + *out = append(*out, Token{Kind: KindKeyword, Value: "for", Position: pos}) + if n.Initializer != nil { + walkNode(n.Initializer, out) + } + if n.Test != nil { + walkNode(n.Test, out) + } + if n.Update != nil { + walkNode(n.Update, out) + } + if n.Body != nil { + walkNode(n.Body, out) + } + + case *ast.ForLoopInitializerExpression: + if n.Expression != nil { + walkNode(n.Expression, out) + } + + case *ast.ForLoopInitializerVarDeclList: + *out = append(*out, Token{Kind: KindKeyword, Value: "var", Position: pos}) + for _, b := range n.List { + walkNode(b, out) + } + + case *ast.ForLoopInitializerLexicalDecl: + walkNode(&n.LexicalDeclaration, out) + + case *ast.ForInStatement: + *out = append(*out, Token{Kind: KindKeyword, Value: "for", Position: pos}) + if n.Into != nil { + walkNode(n.Into, out) + } + *out = append(*out, Token{Kind: KindKeyword, Value: "in", Position: pos}) + if n.Source != nil { + walkNode(n.Source, out) + } + if n.Body != nil { + walkNode(n.Body, out) + } + + case *ast.ForOfStatement: + *out = append(*out, Token{Kind: KindKeyword, Value: "for", Position: pos}) + if n.Into != nil { + walkNode(n.Into, out) + } + *out = append(*out, Token{Kind: KindKeyword, Value: "of", Position: pos}) + if n.Source != nil { + walkNode(n.Source, out) + } + if n.Body != nil { + walkNode(n.Body, out) + } + + case *ast.ForIntoExpression: + if n.Expression != nil { + walkNode(n.Expression, out) + } + + case *ast.ForIntoVar: + *out = append(*out, Token{Kind: KindKeyword, Value: "var", Position: pos}) + if n.Binding != nil { + walkNode(n.Binding, out) + } + + case *ast.ForDeclaration: + kw := "let" + if n.IsConst { + kw = "const" + } + *out = append(*out, Token{Kind: KindKeyword, Value: kw, Position: pos}) + if n.Target != nil { + walkNode(n.Target, out) + } + + case *ast.WhileStatement: + *out = append(*out, Token{Kind: KindKeyword, Value: "while", Position: pos}) + if n.Test != nil { + walkNode(n.Test, out) + } + if n.Body != nil { + walkNode(n.Body, out) + } + + case *ast.WithStatement: + *out = append(*out, Token{Kind: KindKeyword, Value: "with", Position: pos}) + if n.Object != nil { + walkNode(n.Object, out) + } + if n.Body != nil { + walkNode(n.Body, out) + } + + case *ast.DoWhileStatement: + *out = append(*out, Token{Kind: KindKeyword, Value: "do", Position: pos}) + if n.Body != nil { + walkNode(n.Body, out) + } + *out = append(*out, Token{Kind: KindKeyword, Value: "while", Position: pos}) + if n.Test != nil { + walkNode(n.Test, out) + } + + case *ast.ExpressionStatement: + if n.Expression != nil { + walkNode(n.Expression, out) + } + + case *ast.CallExpression: + if n.Callee != nil { + walkNode(n.Callee, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: "(", Position: pos}) + for _, arg := range n.ArgumentList { + walkNode(arg, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: ")", Position: pos}) + + case *ast.DotExpression: + if n.Left != nil { + walkNode(n.Left, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: ".", Position: pos}) + walkNode(&n.Identifier, out) + + case *ast.PrivateDotExpression: + if n.Left != nil { + walkNode(n.Left, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: ".", Position: pos}) + walkNode(&n.Identifier, out) + + case *ast.BracketExpression: + if n.Left != nil { + walkNode(n.Left, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: "[", Position: pos}) + if n.Member != nil { + walkNode(n.Member, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: "]", Position: pos}) + + case *ast.ArrayLiteral: + *out = append(*out, Token{Kind: KindPunct, Value: "[", Position: pos}) + for _, v := range n.Value { + walkNode(v, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: "]", Position: pos}) + + case *ast.ObjectLiteral: + *out = append(*out, Token{Kind: KindPunct, Value: "{", Position: pos}) + for _, p := range n.Value { + walkNode(p, out) + } + *out = append(*out, Token{Kind: KindPunct, Value: "}", Position: pos}) + + case *ast.PropertyKeyed: + if n.Key != nil { + walkNode(n.Key, out) + } + *out = append(*out, Token{Kind: KindOp, Value: ":", Position: pos}) + if n.Value != nil { + walkNode(n.Value, out) + } + + case *ast.PropertyShort: + walkNode(&n.Name, out) + if n.Initializer != nil { + *out = append(*out, Token{Kind: KindOp, Value: "=", Position: pos}) + walkNode(n.Initializer, out) + } + + default: + // Unknown node type: emit a placeholder token so k-gram still has deterministic content. + *out = append(*out, Token{Kind: KindUnknown, Position: pos}) + } +} + +// kgram is an internal type: the hash of a k-token window and the byte offset +// of its first token. The unexported fields are accessed only from within this +// package (tests in the same package may reference them). +type kgram struct { + hash uint64 + position int +} + +// tokenHashInput serializes a token into a deterministic byte sequence for hashing. +// For KindVar/KindStr/KindNum/KindBool/KindNull the Value field is intentionally +// ignored — this is what gives the algorithm its normalization invariance. +func tokenHashInput(t Token) string { + switch t.Kind { + case KindKeyword, KindPunct, KindOp: + return t.Kind.String() + ":" + t.Value + default: + return t.Kind.String() + } +} + +// kgramHash slides a window of size k over the tokens and returns one kgram per +// window position. If len(tokens) < k, returns nil. +func kgramHash(tokens []Token, k int) []kgram { + if k <= 0 || len(tokens) < k { + return nil + } + out := make([]kgram, 0, len(tokens)-k+1) + for i := 0; i+k <= len(tokens); i++ { + h := xxhash.New() + for j := i; j < i+k; j++ { + _, _ = h.WriteString(tokenHashInput(tokens[j])) + _, _ = h.Write([]byte{0}) // separator so "a" + "b" ≠ "ab" + } + out = append(out, kgram{ + hash: h.Sum64(), + position: tokens[i].Position, + }) + } + return out +} + +// winnow selects representative fingerprints from a k-gram sequence using the +// sliding-window minimum algorithm (Schleimer et al. 2003). +// +// Rules: +// 1. In each window of size w, select the k-gram with the minimum hash. +// 2. On ties, select the *rightmost* minimum. Per MOSS §4: if a small edit +// shifts the input by one position, picking the rightmost minimum makes +// it more likely the same k-gram remains selected by the next window, +// reducing fingerprint churn under near-duplicate input. +// 3. If the new window's minimum is the same k-gram as the previously +// selected one, don't re-emit. +// +// Edge cases: +// - Empty input or w <= 0 → returns nil. +// - len(grams) < w → returns a single fingerprint with the overall minimum +// (rightmost on ties). +// +// Complexity: amortized O(n) when hashes are distinct (the monotonic deque +// gives O(1) per slide). Degrades to O(n·w) worst-case when many window +// elements share the minimum hash, because the rightmost-on-ties walk over +// the deque front is O(w) in that pathological case. For Phase 1's <5000-line +// scripts and w=10 this is well within budget. +func winnow(grams []kgram, w int) []FingerprintEntry { + if len(grams) == 0 || w <= 0 { + return nil + } + // If we have fewer grams than window size, the single emission is the + // overall minimum. + if len(grams) < w { + minIdx := 0 + for i := 1; i < len(grams); i++ { + // <= gives rightmost on ties + if grams[i].hash <= grams[minIdx].hash { + minIdx = i + } + } + return []FingerprintEntry{{ + Hash: grams[minIdx].hash, + Position: grams[minIdx].position, + }} + } + + // Helper: find the rightmost-minimum index in grams[lo:hi+1] (linear scan + // over the small initial window). Used only for priming; subsequent updates + // use a monotonic deque. + rightmostMin := func(lo, hi int) int { + idx := lo + for i := lo + 1; i <= hi; i++ { + if grams[i].hash <= grams[idx].hash { + idx = i + } + } + return idx + } + + // deque stores indices into grams; front is the current min candidate. + // We maintain monotonic non-decreasing hashes from front to back. Popping + // with strict > preserves equal elements, supporting rightmost-on-ties. + deque := make([]int, 0, w) + push := func(i int) { + for len(deque) > 0 && grams[deque[len(deque)-1]].hash > grams[i].hash { + deque = deque[:len(deque)-1] + } + deque = append(deque, i) + } + evictBefore := func(i int) { + // Remove indices that have fallen out of the window [i-w+1, i]. + left := i - w + 1 + for len(deque) > 0 && deque[0] < left { + deque = deque[1:] + } + } + + var out []FingerprintEntry + + // Prime the first window. + for i := range w { + push(i) + } + minIdx := rightmostMin(0, w-1) + out = append(out, FingerprintEntry{ + Hash: grams[minIdx].hash, + Position: grams[minIdx].position, + }) + lastSelectedIdx := minIdx + + // Slide the window one step at a time. + for i := w; i < len(grams); i++ { + push(i) + evictBefore(i) + // Front of the deque is one of the minima; walk forward over equal-hash + // entries to find the rightmost. + front := deque[0] + frontHash := grams[front].hash + minIdx = front + for _, idx := range deque { + if grams[idx].hash == frontHash { + minIdx = idx + } else { + break + } + } + if minIdx == lastSelectedIdx { + continue + } + out = append(out, FingerprintEntry{ + Hash: grams[minIdx].hash, + Position: grams[minIdx].position, + }) + lastSelectedIdx = minIdx + } + return out +} + +// ExtractFingerprints is the top-level algorithm entrypoint: it parses the +// JavaScript source, normalizes it to a token stream, slides a k-gram window +// over the tokens, and emits the winnowed fingerprint set. +// +// Zero-valued opts fall back to DefaultOptions(). +// +// On parse failure the returned *FingerprintResult has a nil Fingerprints +// slice and a non-nil ParseError; err is also returned for convenience. +func ExtractFingerprints(code string, opts FingerprintOptions) (*FingerprintResult, error) { + if opts.KGramSize <= 0 { + opts.KGramSize = DefaultOptions().KGramSize + } + if opts.WinnowingWindow <= 0 { + opts.WinnowingWindow = DefaultOptions().WinnowingWindow + } + + tokens, err := parseAndNormalize(code) + if err != nil { + return &FingerprintResult{ParseError: err}, err + } + grams := kgramHash(tokens, opts.KGramSize) + fps := winnow(grams, opts.WinnowingWindow) + return &FingerprintResult{ + Fingerprints: fps, + TotalTokens: len(tokens), + }, nil +} + +// Jaccard computes the Jaccard similarity over the sets of hashes in a and b. +// Positions are ignored. Duplicate hashes within one side are counted once. +// +// Convention: Jaccard(∅, ∅) = 0 — two empty fingerprint sets are treated as +// "no signal" rather than "identical". This avoids spurious 1.0 scores for +// pairs of trivially-short scripts. +// +// This is Phase 1's pure set-based Jaccard. Phase 2's scan flow will compute +// a stop-fingerprint-aware Jaccard by first filtering each side against the +// current stop-fp set. +func Jaccard(a, b []FingerprintEntry) float64 { + if len(a) == 0 && len(b) == 0 { + return 0 + } + setA := make(map[uint64]struct{}, len(a)) + for _, e := range a { + setA[e.Hash] = struct{}{} + } + setB := make(map[uint64]struct{}, len(b)) + for _, e := range b { + setB[e.Hash] = struct{}{} + } + intersect := 0 + for h := range setA { + if _, ok := setB[h]; ok { + intersect++ + } + } + union := len(setA) + len(setB) - intersect + if union == 0 { + return 0 + } + return float64(intersect) / float64(union) +} diff --git a/internal/service/similarity_svc/fingerprint_test.go b/internal/service/similarity_svc/fingerprint_test.go new file mode 100644 index 0000000..966c46c --- /dev/null +++ b/internal/service/similarity_svc/fingerprint_test.go @@ -0,0 +1,826 @@ +package similarity_svc + +import ( + "math/rand" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTokenKind_String(t *testing.T) { + cases := []struct { + kind TokenKind + name string + }{ + {KindUnknown, "UNK"}, + {KindVar, "VAR"}, + {KindStr, "STR"}, + {KindNum, "NUM"}, + {KindBool, "BOOL"}, + {KindNull, "NULL"}, + {KindKeyword, "KW"}, + {KindPunct, "PUNCT"}, + {KindOp, "OP"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.name, c.kind.String()) + }) + } +} + +func TestDefaultOptions(t *testing.T) { + opts := DefaultOptions() + assert.Equal(t, 5, opts.KGramSize) + assert.Equal(t, 10, opts.WinnowingWindow) +} + +func TestParseAndNormalize_SimpleFunction(t *testing.T) { + code := `function foo(x) { return x + 1; }` + tokens, err := parseAndNormalize(code) + assert.NoError(t, err) + assert.NotEmpty(t, tokens) + + // Count token kinds — exact count is implementation-defined, + // but these minimums must hold for a function with 2 identifiers and 1 number. + var varCount, numCount, kwCount int + for _, tk := range tokens { + switch tk.Kind { + case KindVar: + varCount++ + case KindNum: + numCount++ + case KindKeyword: + kwCount++ + } + } + assert.GreaterOrEqual(t, varCount, 2, "expected at least 2 VAR tokens (foo, x)") + assert.GreaterOrEqual(t, numCount, 1, "expected at least 1 NUM token") + assert.GreaterOrEqual(t, kwCount, 1, "expected at least 1 KW token (function or return)") +} + +func TestParseAndNormalize_StringLiteral(t *testing.T) { + code := `var greeting = "hello world";` + tokens, err := parseAndNormalize(code) + assert.NoError(t, err) + + strCount := 0 + for _, tk := range tokens { + if tk.Kind == KindStr { + strCount++ + } + } + assert.Equal(t, 1, strCount) +} + +func TestParseAndNormalize_BooleanAndNull(t *testing.T) { + code := `var a = true; var b = false; var c = null;` + tokens, err := parseAndNormalize(code) + assert.NoError(t, err) + + var boolCount, nullCount int + for _, tk := range tokens { + switch tk.Kind { + case KindBool: + boolCount++ + case KindNull: + nullCount++ + } + } + assert.Equal(t, 2, boolCount) + assert.Equal(t, 1, nullCount) +} + +func TestParseAndNormalize_InvalidSyntax(t *testing.T) { + _, err := parseAndNormalize(`function {{{ broken`) + assert.Error(t, err) +} + +// TestParseAndNormalize_ClassDeclaration guards against the regression where +// walkNode had no case for *ast.ClassDeclaration / ClassLiteral / MethodDefinition +// and emitted a single KindUnknown for any top-level class — collapsing 1000-line +// userscripts down to < 14 tokens and tripping the too_few_fingerprints skip. +func TestParseAndNormalize_ClassDeclaration(t *testing.T) { + code := ` + class Animal { + constructor(name) { this.name = name; } + speak() { return this.name + " makes a sound."; } + static create(name) { return new Animal(name); } + } + class Dog extends Animal { + #secret = 42; + speak() { return this.name + " barks."; } + get secret() { return this.#secret; } + } + ` + tokens, err := parseAndNormalize(code) + assert.NoError(t, err) + assert.Greater(t, len(tokens), 30, "class body must descend into method bodies") + + // Must see method bodies (not just a KindUnknown placeholder). + var kwClass, kwReturn, kwThis, kwSuper, kwExtends int + for _, tk := range tokens { + if tk.Kind == KindKeyword { + switch tk.Value { + case "class": + kwClass++ + case "return": + kwReturn++ + case "this": + kwThis++ + case "super": + kwSuper++ + case "extends": + kwExtends++ + } + } + } + assert.GreaterOrEqual(t, kwClass, 2, "expected 2 class keyword tokens") + assert.GreaterOrEqual(t, kwReturn, 3, "expected return inside method bodies") + assert.GreaterOrEqual(t, kwThis, 3, "expected this inside method bodies") + _ = kwSuper // not all class bodies use super + assert.GreaterOrEqual(t, kwExtends, 1, "expected extends keyword for Dog") +} + +// TestParseAndNormalize_ModernSyntax exercises the other ES6+ constructs that +// walkNode now handles: arrow functions, let/const, template literals, +// destructuring, async/await, optional chaining, spread. +func TestParseAndNormalize_ModernSyntax(t *testing.T) { + code := "" + + "const add = (a, b) => a + b;\n" + + "let { x, y, ...rest } = obj;\n" + + "const [first, ...tail] = arr;\n" + + "const greeting = `hello ${name}!`;\n" + + "async function fetchUser(id) {\n" + + " try {\n" + + " const r = await fetch(`/u/${id}`);\n" + + " return r?.data ?? null;\n" + + " } catch (e) { throw new Error(e.message); }\n" + + "}\n" + + "for (const item of items) { console.log(item); }\n" + + "switch (x) { case 1: break; default: return; }\n" + tokens, err := parseAndNormalize(code) + assert.NoError(t, err) + assert.Greater(t, len(tokens), 60, "modern syntax must still produce a dense token stream") + + kw := map[string]int{} + for _, tk := range tokens { + if tk.Kind == KindKeyword { + kw[tk.Value]++ + } + } + assert.GreaterOrEqual(t, kw["const"], 3, "let/const handling") + assert.GreaterOrEqual(t, kw["let"], 1, "let handling") + assert.GreaterOrEqual(t, kw["await"], 1, "await handling") + assert.GreaterOrEqual(t, kw["throw"], 1, "throw handling") + assert.GreaterOrEqual(t, kw["try"], 1, "try handling") + assert.GreaterOrEqual(t, kw["catch"], 1, "catch handling") + assert.GreaterOrEqual(t, kw["for"], 1, "for-of handling") + assert.GreaterOrEqual(t, kw["of"], 1, "for-of handling") + assert.GreaterOrEqual(t, kw["switch"], 1, "switch handling") + assert.GreaterOrEqual(t, kw["case"], 1, "case handling") + assert.GreaterOrEqual(t, kw["default"], 1, "default handling") +} + +// TestParseAndNormalize_WalkNodeExtras pins the remaining ES5+/ES6+ AST cases +// not exercised by ClassDeclaration / ModernSyntax above. Each construct must +// (a) parse, (b) emit the expected anchor keyword/operator so the walker is +// known to descend into it instead of dropping to the KindUnknown default. +func TestParseAndNormalize_WalkNodeExtras(t *testing.T) { + cases := []struct { + name string + code string + wantKW []string // every keyword value here must appear at least once + wantOps []string // every op value here must appear at least once + minToken int // sanity floor on total token count + }{ + { + name: "do-while + for-in + labeled-continue", + code: "outer: for (var k in obj) { do { if (k === 'skip') continue outer; } while (false); }", + wantKW: []string{"for", "in", "do", "while", "if", "continue"}, + minToken: 15, + }, + { + name: "generator with yield + new + sequence + regexp", + code: "function* gen() { var re = /abc/g; var x = (1, 2, 3); yield new Map(); yield re; }", + wantKW: []string{"function", "yield", "new", "var"}, + minToken: 20, + }, + { + name: "with statement (legacy)", + code: "with (Math) { return sqrt(16) + PI; }", + wantKW: []string{"with", "return"}, + minToken: 8, + }, + { + name: "spread in call args + array spread", + code: "function f(...args) { return Math.max(...args, ...[1, 2, 3]); }", + wantOps: []string{"..."}, + wantKW: []string{"function", "return"}, + minToken: 12, + }, + { + name: "meta-property new.target", + code: "function Ctor() { if (new.target === undefined) { throw new Error('call with new'); } }", + wantKW: []string{"function", "if", "throw", "new"}, + minToken: 12, + }, + { + name: "class static block + private field", + code: "class C { static #count = 0; static { C.#count = 42; } get count() { return C.#count; } }", + wantKW: []string{"class", "static", "return"}, + minToken: 15, + }, + { + name: "debugger + empty statement", + code: "function dbg() { ; ; debugger; return 1; }", + wantKW: []string{"function", "debugger", "return"}, + minToken: 8, + }, + { + name: "ternary + unary + optional chain", + code: "var x = !flag ? -1 : obj?.field?.value;", + wantKW: []string{"var"}, + wantOps: []string{"?", ":", "!", "-", "?."}, + minToken: 8, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tokens, err := parseAndNormalize(tc.code) + assert.NoError(t, err, "parse failed: %s", tc.code) + assert.GreaterOrEqual(t, len(tokens), tc.minToken, + "too few tokens — walker probably hit KindUnknown default") + + seenKW := map[string]bool{} + seenOp := map[string]bool{} + for _, tk := range tokens { + if tk.Kind == KindKeyword { + seenKW[tk.Value] = true + } + if tk.Kind == KindOp { + seenOp[tk.Value] = true + } + } + for _, want := range tc.wantKW { + assert.True(t, seenKW[want], + "missing expected keyword %q (kw=%v)", want, seenKW) + } + for _, want := range tc.wantOps { + assert.True(t, seenOp[want], + "missing expected op %q (ops=%v)", want, seenOp) + } + }) + } +} + +// TestParseAndNormalize_ObjectLiteralProperties guards the PropertyKeyed / +// PropertyShort fix: the old walker treated object property entries as +// ast.Expression (which they are not) and silently emitted KindUnknown for +// every property — so a script consisting mostly of literal config objects +// produced ~0 useful tokens. Goja represents identifier keys as StringLiteral, +// so unquoted keys still produce KindStr (which is what we want — they +// normalize to literals just like the values do). +func TestParseAndNormalize_ObjectLiteralProperties(t *testing.T) { + code := `var cfg = { name: "ocs", version: 4, enabled: true, retries: 3, target: null, ...defaults };` + tokens, err := parseAndNormalize(code) + assert.NoError(t, err) + + var strs, nums, bools, nulls, ops int + for _, tk := range tokens { + switch tk.Kind { + case KindStr: + strs++ + case KindNum: + nums++ + case KindBool: + bools++ + case KindNull: + nulls++ + case KindOp: + ops++ + } + } + // 5 property keys + 1 string value = ≥ 6 KindStr. + assert.GreaterOrEqual(t, strs, 6, "expected property keys + string values") + assert.GreaterOrEqual(t, nums, 2, "expected number literals from version/retries") + assert.GreaterOrEqual(t, bools, 1, "expected boolean from enabled") + assert.GreaterOrEqual(t, nulls, 1, "expected null from target") + // Spread "..." op + ":" between key/value (5x) + "=" from var binding. + assert.GreaterOrEqual(t, ops, 6, "expected spread, colons, and assignment ops") +} + +// TestExtractFingerprints_ClassBodyProducesManyFingerprints — end-to-end guard: +// a class-only file ~30 lines must yield well above MinFingerprints (20). Before +// the walkNode rewrite this would yield 1 fingerprint and trip the skip path. +func TestExtractFingerprints_ClassBodyProducesManyFingerprints(t *testing.T) { + code := ` + class Calc { + constructor() { this.acc = 0; } + add(x) { this.acc += x; return this; } + sub(x) { this.acc -= x; return this; } + mul(x) { this.acc *= x; return this; } + div(x) { if (x === 0) throw new Error("div by zero"); this.acc /= x; return this; } + value() { return this.acc; } + reset() { this.acc = 0; return this; } + } + const c = new Calc(); + const result = c.add(5).mul(3).sub(2).div(13).value(); + ` + r, err := ExtractFingerprints(code, DefaultOptions()) + assert.NoError(t, err) + assert.NotNil(t, r) + assert.Nil(t, r.ParseError) + assert.Greater(t, r.TotalTokens, 50, "expected dense token stream from class body") + assert.Greater(t, len(r.Fingerprints), 5, "expected multiple fingerprints from class body") +} + +func TestParseAndNormalize_TokensHavePositions(t *testing.T) { + code := `var x = 42;` + tokens, err := parseAndNormalize(code) + assert.NoError(t, err) + assert.NotEmpty(t, tokens) + for _, tk := range tokens { + assert.GreaterOrEqual(t, tk.Position, 0) + assert.Less(t, tk.Position, len(code)) + } +} + +func TestParseAndNormalize_OperatorEmitsKindOp(t *testing.T) { + code := `var x = 1 + 2;` + tokens, err := parseAndNormalize(code) + assert.NoError(t, err) + + var opTokens []Token + for _, tk := range tokens { + if tk.Kind == KindOp { + opTokens = append(opTokens, tk) + } + } + // Expect at least two operators: "=" from var initialization and "+" from binary expression. + assert.GreaterOrEqual(t, len(opTokens), 2, "expected at least 2 operator tokens") + + // At least one operator should have value "+". + hasPlus := false + for _, tk := range opTokens { + if tk.Value == "+" { + hasPlus = true + break + } + } + assert.True(t, hasPlus, "expected at least one KindOp token with Value=\"+\"") +} + +func TestKGramHash_BasicSlicing(t *testing.T) { + tokens := []Token{ + {Kind: KindKeyword, Value: "function", Position: 0}, + {Kind: KindVar, Position: 9}, + {Kind: KindPunct, Value: "(", Position: 12}, + {Kind: KindVar, Position: 13}, + {Kind: KindPunct, Value: ")", Position: 14}, + {Kind: KindPunct, Value: "{", Position: 16}, + {Kind: KindKeyword, Value: "return", Position: 18}, + {Kind: KindVar, Position: 25}, + {Kind: KindPunct, Value: ";", Position: 26}, + {Kind: KindPunct, Value: "}", Position: 28}, + } + grams := kgramHash(tokens, 5) + // For 10 tokens and k=5, expect 10-5+1 = 6 k-grams. + assert.Len(t, grams, 6) + // Every gram must have a monotonically non-decreasing position. + for i := 1; i < len(grams); i++ { + assert.GreaterOrEqual(t, grams[i].position, grams[i-1].position) + } + // Every gram's position must equal its starting token's position. + for i, g := range grams { + assert.Equal(t, tokens[i].Position, g.position) + } +} + +func TestKGramHash_FewerTokensThanK(t *testing.T) { + tokens := []Token{ + {Kind: KindVar, Position: 0}, + {Kind: KindVar, Position: 5}, + } + grams := kgramHash(tokens, 5) + assert.Empty(t, grams, "when token count < k, no k-grams should be produced") +} + +func TestKGramHash_DeterministicHash(t *testing.T) { + tokens := []Token{ + {Kind: KindVar, Position: 0}, + {Kind: KindOp, Value: "+", Position: 1}, + {Kind: KindNum, Position: 2}, + {Kind: KindOp, Value: "*", Position: 3}, + {Kind: KindVar, Position: 4}, + } + g1 := kgramHash(tokens, 5) + g2 := kgramHash(tokens, 5) + assert.Equal(t, g1, g2, "same input must produce identical k-gram hashes") + assert.Len(t, g1, 1) +} + +func TestKGramHash_IgnoresValueForVar(t *testing.T) { + // Two VAR tokens with different (unused) Values should produce the same hash: + // the whole point of normalization is that variable names don't matter. + left := []Token{ + {Kind: KindVar, Value: "foo", Position: 0}, + {Kind: KindOp, Value: "+", Position: 4}, + {Kind: KindVar, Value: "bar", Position: 6}, + {Kind: KindOp, Value: "*", Position: 10}, + {Kind: KindVar, Value: "baz", Position: 12}, + } + right := []Token{ + {Kind: KindVar, Value: "a", Position: 0}, + {Kind: KindOp, Value: "+", Position: 2}, + {Kind: KindVar, Value: "b", Position: 4}, + {Kind: KindOp, Value: "*", Position: 6}, + {Kind: KindVar, Value: "c", Position: 8}, + } + l := kgramHash(left, 5) + r := kgramHash(right, 5) + assert.Equal(t, l[0].hash, r[0].hash, + "Var token Value must not affect hash (normalization invariance)") +} + +func TestKGramHash_DistinguishesOperatorValues(t *testing.T) { + // Mirror of TestKGramHash_IgnoresValueForVar: KW/OP/PUNCT tokens have their + // Value mixed into the hash so that "+" and "-" produce different fingerprints. + a := []Token{ + {Kind: KindVar, Position: 0}, + {Kind: KindOp, Value: "+", Position: 1}, + {Kind: KindVar, Position: 2}, + {Kind: KindOp, Value: "+", Position: 3}, + {Kind: KindVar, Position: 4}, + } + b := []Token{ + {Kind: KindVar, Position: 0}, + {Kind: KindOp, Value: "-", Position: 1}, + {Kind: KindVar, Position: 2}, + {Kind: KindOp, Value: "-", Position: 3}, + {Kind: KindVar, Position: 4}, + } + ah := kgramHash(a, 5) + bh := kgramHash(b, 5) + assert.Len(t, ah, 1) + assert.Len(t, bh, 1) + assert.NotEqual(t, ah[0].hash, bh[0].hash, + "operator Value must affect hash; otherwise + and - are indistinguishable") +} + +func TestKGramHash_ZeroOrNegativeK(t *testing.T) { + tokens := []Token{ + {Kind: KindVar, Position: 0}, + {Kind: KindVar, Position: 5}, + } + assert.Nil(t, kgramHash(tokens, 0)) + assert.Nil(t, kgramHash(tokens, -1)) +} + +func TestWinnow_PicksMinimumPerWindow(t *testing.T) { + // Hashes: [5, 3, 9, 1, 8, 4, 7, 2] + // Window size 3. + // Window positions and expected minimums: + // [5,3,9] → 3 (index 1, position 10) + // [3,9,1] → 1 (index 3, position 30) + // [9,1,8] → 1 (index 3, deduped) + // [1,8,4] → 1 (index 3, deduped) + // [8,4,7] → 4 (index 5, position 50) + // [4,7,2] → 2 (index 7, position 70) + grams := []kgram{ + {hash: 5, position: 0}, + {hash: 3, position: 10}, + {hash: 9, position: 20}, + {hash: 1, position: 30}, + {hash: 8, position: 40}, + {hash: 4, position: 50}, + {hash: 7, position: 60}, + {hash: 2, position: 70}, + } + fps := winnow(grams, 3) + + // Expected sequence of hashes after dedup: 3, 1, 4, 2 — four fingerprints. + assert.Len(t, fps, 4) + assert.Equal(t, uint64(3), fps[0].Hash) + assert.Equal(t, 10, fps[0].Position) + assert.Equal(t, uint64(1), fps[1].Hash) + assert.Equal(t, 30, fps[1].Position) + assert.Equal(t, uint64(4), fps[2].Hash) + assert.Equal(t, 50, fps[2].Position) + assert.Equal(t, uint64(2), fps[3].Hash) + assert.Equal(t, 70, fps[3].Position) +} + +func TestWinnow_FewerGramsThanWindow(t *testing.T) { + // When we have fewer k-grams than the window size, winnowing reduces to + // "pick the overall minimum". + grams := []kgram{ + {hash: 5, position: 0}, + {hash: 2, position: 10}, + {hash: 8, position: 20}, + } + fps := winnow(grams, 10) + assert.Len(t, fps, 1) + assert.Equal(t, uint64(2), fps[0].Hash) + assert.Equal(t, 10, fps[0].Position) +} + +func TestWinnow_EmptyInput(t *testing.T) { + assert.Empty(t, winnow(nil, 5)) + assert.Empty(t, winnow([]kgram{}, 5)) +} + +func TestWinnow_ZeroOrNegativeWindow(t *testing.T) { + grams := []kgram{ + {hash: 1, position: 0}, + {hash: 2, position: 10}, + } + assert.Empty(t, winnow(grams, 0)) + assert.Empty(t, winnow(grams, -1)) +} + +func TestWinnow_TiebreakPicksRightmost(t *testing.T) { + // Classic winnowing rule: on ties, pick the *rightmost* minimum so that + // shifts of the window don't cause spurious re-selections. + grams := []kgram{ + {hash: 3, position: 0}, + {hash: 3, position: 10}, + {hash: 3, position: 20}, + {hash: 5, position: 30}, + } + fps := winnow(grams, 3) + // First window [3,3,3]: rightmost minimum is position 20. + // Second window [3,3,5]: minimum is position 20 (same as above, deduped). + assert.Len(t, fps, 1) + assert.Equal(t, 20, fps[0].Position) +} + +func TestWinnow_InvariantAgainstBruteForce(t *testing.T) { + // Property test: cross-check winnow's output against a brute-force + // rightmost-window-minimum implementation across many random inputs. + // Uses a small hash alphabet to maximize tie collisions, which is where + // rightmost-on-ties + dedup logic is most likely to fail. + rng := rand.New(rand.NewSource(42)) + for trial := range 100 { + n := 1 + rng.Intn(50) + w := 1 + rng.Intn(8) + grams := make([]kgram, n) + for i := range grams { + grams[i] = kgram{ + hash: uint64(rng.Intn(5)), // small alphabet → many ties + position: i * 10, + } + } + + got := winnow(grams, w) + expected := bruteForceWinnow(grams, w) + assert.Equal(t, expected, got, + "trial %d (n=%d, w=%d): winnow output mismatch", trial, n, w) + } +} + +// bruteForceWinnow is a slow reference implementation of winnow used only +// in tests. It enumerates each window, finds the rightmost minimum via +// linear scan, applies dedup-on-same-index, and emits FingerprintEntry slices. +func bruteForceWinnow(grams []kgram, w int) []FingerprintEntry { + if len(grams) == 0 || w <= 0 { + return nil + } + if len(grams) < w { + idx := 0 + for i := 1; i < len(grams); i++ { + if grams[i].hash <= grams[idx].hash { + idx = i + } + } + return []FingerprintEntry{{Hash: grams[idx].hash, Position: grams[idx].position}} + } + var out []FingerprintEntry + last := -1 + for start := 0; start+w <= len(grams); start++ { + idx := start + for i := start + 1; i < start+w; i++ { + if grams[i].hash <= grams[idx].hash { + idx = i + } + } + if idx != last { + out = append(out, FingerprintEntry{Hash: grams[idx].hash, Position: grams[idx].position}) + last = idx + } + } + return out +} + +func TestExtractFingerprints_NormalCode(t *testing.T) { + code := `function greet(name) { return "hello " + name; } +function farewell(name) { return "bye " + name; }` + result, err := ExtractFingerprints(code, DefaultOptions()) + assert.NoError(t, err) + assert.Nil(t, result.ParseError) + assert.NotEmpty(t, result.Fingerprints) + assert.Greater(t, result.TotalTokens, 0) + // Every fingerprint position must point to a valid byte offset inside the + // source. (Positions are not strictly monotonic: closing punctuation tokens + // inherit the opener's position from walkNode, so a "}" can appear at an + // earlier position than the body tokens it closes. The real contract is + // just bounds-validity.) + for _, fp := range result.Fingerprints { + assert.GreaterOrEqual(t, fp.Position, 0) + assert.Less(t, fp.Position, len(code)) + } +} + +func TestExtractFingerprints_InvalidSyntax(t *testing.T) { + result, err := ExtractFingerprints(`function {{{ broken`, DefaultOptions()) + assert.Error(t, err) + assert.NotNil(t, result) + assert.NotNil(t, result.ParseError) + assert.Empty(t, result.Fingerprints) +} + +// ScriptCat wraps background/cron scripts in an async function at runtime, so +// top-level `return` / `await` are legal in practice. The fingerprint parser +// must retry with that wrapper so these scripts still produce fingerprints. +func TestExtractFingerprints_TopLevelReturn(t *testing.T) { + code := `const x = 1; +return new Promise((resolve) => { + resolve(x + 2); +});` + result, err := ExtractFingerprints(code, DefaultOptions()) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Nil(t, result.ParseError) + assert.Greater(t, result.TotalTokens, 0) + // Positions must still fall inside the original (unwrapped) source. + for _, fp := range result.Fingerprints { + assert.GreaterOrEqual(t, fp.Position, 0) + assert.Less(t, fp.Position, len(code)) + } +} + +func TestExtractFingerprints_TopLevelAwait(t *testing.T) { + code := `const data = await fetch("/api/x"); +console.log(data);` + result, err := ExtractFingerprints(code, DefaultOptions()) + assert.NoError(t, err) + assert.Nil(t, result.ParseError) + assert.Greater(t, result.TotalTokens, 0) +} + +func TestExtractFingerprints_EmptyInput(t *testing.T) { + result, err := ExtractFingerprints("", DefaultOptions()) + assert.NoError(t, err) + assert.Empty(t, result.Fingerprints) + assert.Equal(t, 0, result.TotalTokens) + assert.Nil(t, result.ParseError) +} + +func TestExtractFingerprints_ZeroOptionsFallBackToDefaults(t *testing.T) { + code := `function foo(x) { return x + 1; } function bar(y) { return y * 2; }` + result1, err1 := ExtractFingerprints(code, FingerprintOptions{}) + result2, err2 := ExtractFingerprints(code, DefaultOptions()) + assert.NoError(t, err1) + assert.NoError(t, err2) + assert.Equal(t, result1.Fingerprints, result2.Fingerprints, + "zero options should fall back to defaults") +} + +func TestExtractFingerprints_OptionsAffectOutput(t *testing.T) { + code := `function foo(x) { return x + 1; } function bar(y) { return y * 2; }` + small, err := ExtractFingerprints(code, FingerprintOptions{KGramSize: 3, WinnowingWindow: 4}) + assert.NoError(t, err) + dflt, err := ExtractFingerprints(code, DefaultOptions()) + assert.NoError(t, err) + assert.NotEqual(t, small.Fingerprints, dflt.Fingerprints, + "non-default options should produce a different fingerprint set") +} + +func TestJaccard_Identical(t *testing.T) { + a := []FingerprintEntry{{Hash: 1}, {Hash: 2}, {Hash: 3}} + b := []FingerprintEntry{{Hash: 1}, {Hash: 2}, {Hash: 3}} + assert.InDelta(t, 1.0, Jaccard(a, b), 1e-9) +} + +func TestJaccard_Disjoint(t *testing.T) { + a := []FingerprintEntry{{Hash: 1}, {Hash: 2}} + b := []FingerprintEntry{{Hash: 3}, {Hash: 4}} + assert.InDelta(t, 0.0, Jaccard(a, b), 1e-9) +} + +func TestJaccard_Partial(t *testing.T) { + a := []FingerprintEntry{{Hash: 1}, {Hash: 2}, {Hash: 3}} + b := []FingerprintEntry{{Hash: 2}, {Hash: 3}, {Hash: 4}} + // Intersection: {2, 3} (size 2) + // Union: {1, 2, 3, 4} (size 4) + // Jaccard: 2/4 = 0.5 + assert.InDelta(t, 0.5, Jaccard(a, b), 1e-9) +} + +func TestJaccard_BothEmpty(t *testing.T) { + // Convention: Jaccard(∅, ∅) = 0 (not 1 or NaN). Rationale: two scripts with + // zero fingerprints are both untestable, not "identical". + assert.Equal(t, 0.0, Jaccard(nil, nil)) +} + +func TestJaccard_OneEmpty(t *testing.T) { + a := []FingerprintEntry{{Hash: 1}, {Hash: 2}} + assert.Equal(t, 0.0, Jaccard(a, nil)) + assert.Equal(t, 0.0, Jaccard(nil, a)) +} + +func TestJaccard_DuplicateHashesCountOnce(t *testing.T) { + // Winnowing can emit the same hash multiple times at different positions + // (though dedup usually prevents it). Jaccard works on sets, so duplicates + // within one side should count once. + a := []FingerprintEntry{{Hash: 1}, {Hash: 1}, {Hash: 2}} + b := []FingerprintEntry{{Hash: 1}, {Hash: 2}} + // Set A = {1, 2}, Set B = {1, 2}, intersection=2, union=2 → 1.0 + assert.InDelta(t, 1.0, Jaccard(a, b), 1e-9) +} + +func TestJaccard_PositionsIgnored(t *testing.T) { + // Two fingerprint entries with same hash but different positions should + // still count as the same set element. + a := []FingerprintEntry{{Hash: 1, Position: 0}, {Hash: 2, Position: 5}} + b := []FingerprintEntry{{Hash: 1, Position: 100}, {Hash: 2, Position: 200}} + assert.InDelta(t, 1.0, Jaccard(a, b), 1e-9) +} + +func TestJaccard_Symmetric(t *testing.T) { + // Jaccard(a, b) must equal Jaccard(b, a) for any inputs. Structural + // symmetry is guaranteed by the implementation, but a pinned test guards + // against future "always iterate the smaller set" optimizations from + // silently breaking it. + a := []FingerprintEntry{{Hash: 1}, {Hash: 2}, {Hash: 3}, {Hash: 5}} + b := []FingerprintEntry{{Hash: 2}, {Hash: 3}, {Hash: 4}} + assert.InDelta(t, Jaccard(a, b), Jaccard(b, a), 1e-12) +} + +func TestJaccard_ProperSubset(t *testing.T) { + // A ⊂ B strict subset case: distinct from the partial-overlap case + // (where neither set contains the other). + a := []FingerprintEntry{{Hash: 1}, {Hash: 2}} + b := []FingerprintEntry{{Hash: 1}, {Hash: 2}, {Hash: 3}, {Hash: 4}} + // Intersection = {1, 2} (2), Union = {1, 2, 3, 4} (4) → 0.5 + assert.InDelta(t, 0.5, Jaccard(a, b), 1e-9) +} + +func loadTestdata(t *testing.T, relPath string) string { + t.Helper() + // #nosec G304 -- test helper, relPath is a hardcoded literal from the test. + data, err := os.ReadFile(filepath.Join("testdata", relPath)) + assert.NoError(t, err) + return string(data) +} + +func TestExtractFingerprints_RenameInvariance(t *testing.T) { + original := loadTestdata(t, "rename_pair/original.js") + renamed := loadTestdata(t, "rename_pair/renamed.js") + + a, err := ExtractFingerprints(original, DefaultOptions()) + assert.NoError(t, err) + b, err := ExtractFingerprints(renamed, DefaultOptions()) + assert.NoError(t, err) + + sim := Jaccard(a.Fingerprints, b.Fingerprints) + // The two scripts differ only in variable and parameter names. With + // AST normalization, fingerprints should be very close to identical. + assert.GreaterOrEqual(t, sim, 0.95, + "rename-only changes should preserve >= 95%% of fingerprints (got %.3f)", sim) +} + +func TestExtractFingerprints_ReorderSimilarity(t *testing.T) { + original := loadTestdata(t, "reorder_pair/original.js") + reordered := loadTestdata(t, "reorder_pair/reordered.js") + + a, err := ExtractFingerprints(original, DefaultOptions()) + assert.NoError(t, err) + b, err := ExtractFingerprints(reordered, DefaultOptions()) + assert.NoError(t, err) + + sim := Jaccard(a.Fingerprints, b.Fingerprints) + // Same functions, different order. k-gram local features should be almost + // entirely preserved; only the transitions between functions differ. + assert.GreaterOrEqual(t, sim, 0.80, + "reorder-only changes should preserve >= 80%% of fingerprints (got %.3f)", sim) +} + +func TestExtractFingerprints_UnrelatedCodeDisjoint(t *testing.T) { + codeA := loadTestdata(t, "different_pair/a.js") + codeB := loadTestdata(t, "different_pair/b.js") + + a, err := ExtractFingerprints(codeA, DefaultOptions()) + assert.NoError(t, err) + b, err := ExtractFingerprints(codeB, DefaultOptions()) + assert.NoError(t, err) + + sim := Jaccard(a.Fingerprints, b.Fingerprints) + // Two small, unrelated utility files. Some overlap is expected from + // structural boilerplate (function/return/var/etc.) but content fingerprints + // should diverge heavily. + assert.Less(t, sim, 0.30, + "unrelated code should have low similarity (got %.3f)", sim) +} diff --git a/internal/service/similarity_svc/integrity.go b/internal/service/similarity_svc/integrity.go new file mode 100644 index 0000000..bbe1205 --- /dev/null +++ b/internal/service/similarity_svc/integrity.go @@ -0,0 +1,240 @@ +package similarity_svc + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/cago-frame/cago/pkg/logger" + "github.com/scriptscat/scriptlist/configs" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + "go.uber.org/zap" +) + +//go:generate mockgen -source=integrity.go -destination=mock/integrity.go + +type IntegritySvc interface { + Check(ctx context.Context, code string) *IntegrityResult + CheckFast(ctx context.Context, code string) *IntegrityResult + IsWhitelisted(ctx context.Context, scriptID int64) (bool, error) + RecordWarning(ctx context.Context, scriptID, scriptCodeID, userID int64, result *IntegrityResult) error +} + +var defaultIntegrity IntegritySvc + +func Integrity() IntegritySvc { return defaultIntegrity } + +func RegisterIntegrity(svc IntegritySvc) { defaultIntegrity = svc } + +type integritySvc struct{} + +func NewIntegritySvc() IntegritySvc { return &integritySvc{} } + +// signalDef pairs a detector with its category weight + display name. All +// signals receive a shared *codeFeatures so precomputed artifacts (line stats, +// identifier list, comment byte count) are reused across the category. +type signalDef struct { + Name string + Cat string // "A", "B", "C", "D" + Phase signalPhase + Fn func(*codeFeatures) float64 +} + +var allSignals = []signalDef{ + {"avg_line_length", "A", phaseSync, featAvgLineLength}, + {"max_line_length", "A", phaseSync, featMaxLineLength}, + {"whitespace_ratio", "A", phaseSync, featWhitespaceRatio}, + {"comment_ratio", "A", phaseAsync, featCommentRatio}, + {"single_char_ident_ratio", "B", phaseAsync, featSingleCharIdentRatio}, + {"hex_ident_ratio", "B", phaseAsync, featHexIdentRatio}, + {"large_string_array", "C", phaseAsync, featLargeStringArray}, + {"dean_edwards_packer", "D", phaseSync, featDeanEdwardsPacker}, + {"eval_density", "D", phaseAsync, featEvalDensity}, +} + +var knownPackerSignals = map[string]bool{ + "dean_edwards_packer": true, +} + +// Per-category weights from spec §10.3. +var catWeights = map[string]float64{ + "A": 0.25, + "B": 0.30, + "C": 0.20, + "D": 0.25, +} + +type checkOptions struct { + syncOnly bool +} + +func runSignals(code string, opts checkOptions) (score float64, subScores map[string]float64, hits []SignalHit, knownPacker bool) { + features := newCodeFeatures(code) + cat := map[string]float64{"A": 0, "B": 0, "C": 0, "D": 0} + hits = make([]SignalHit, 0) + for _, sig := range allSignals { + if opts.syncOnly && sig.Phase != phaseSync { + continue + } + v := sig.Fn(features) + if v > cat[sig.Cat] { + cat[sig.Cat] = v + } + if v > 0 { + hits = append(hits, SignalHit{ + Name: sig.Name, + Value: v, + Threshold: 1.0, + }) + if opts.syncOnly && knownPackerSignals[sig.Name] { + knownPacker = true + } + } + } + if knownPacker { + score = 1.0 + } else { + for c, w := range catWeights { + score += w * cat[c] + } + if score > 1 { + score = 1 + } + } + subScores = map[string]float64{ + "cat_a": cat["A"], + "cat_b": cat["B"], + "cat_c": cat["C"], + "cat_d": cat["D"], + } + return +} + +func (s *integritySvc) Check(ctx context.Context, code string) *IntegrityResult { + score, subScores, hits, _ := runSignals(code, checkOptions{}) + hitNames := make([]string, 0, len(hits)) + for _, h := range hits { + hitNames = append(hitNames, h.Name) + } + logger.Ctx(ctx).Debug("integrity check done", + zap.Int("code_len", len(code)), + zap.Float64("score", score), + zap.Int("hit_count", len(hits)), + zap.Strings("hit_signals", hitNames), + ) + return &IntegrityResult{ + Score: score, + SubScores: subScores, + HitSignals: hits, + } +} + +func (s *integritySvc) CheckFast(ctx context.Context, code string) *IntegrityResult { + score, subScores, hits, knownPacker := runSignals(code, checkOptions{syncOnly: true}) + hitNames := make([]string, 0, len(hits)) + for _, h := range hits { + hitNames = append(hitNames, h.Name) + } + logger.Ctx(ctx).Debug("integrity check fast done", + zap.Int("code_len", len(code)), + zap.Float64("score", score), + zap.Bool("known_packer", knownPacker), + zap.Int("hit_count", len(hits)), + zap.Strings("hit_signals", hitNames), + ) + return &IntegrityResult{ + Score: score, + SubScores: subScores, + HitSignals: hits, + KnownPacker: knownPacker, + Partial: true, + } +} + +func (s *integritySvc) IsWhitelisted(ctx context.Context, scriptID int64) (bool, error) { + return similarity_repo.IntegrityWhitelist().IsWhitelisted(ctx, scriptID) +} + +func (s *integritySvc) RecordWarning(ctx context.Context, scriptID, scriptCodeID, userID int64, result *IntegrityResult) error { + log := logger.Ctx(ctx).With( + zap.Int64("script_id", scriptID), + zap.Int64("script_code_id", scriptCodeID), + zap.Int64("user_id", userID), + zap.Float64("score", result.Score), + ) + subScoresJSON, err := json.Marshal(result.SubScores) + if err != nil { + log.Error("integrity record: marshal sub_scores failed", zap.Error(err)) + return err + } + hitsJSON, err := json.Marshal(result.HitSignals) + if err != nil { + log.Error("integrity record: marshal hit_signals failed", zap.Error(err)) + return err + } + now := time.Now().Unix() + row := &similarity_entity.IntegrityReview{ + ScriptID: scriptID, + ScriptCodeID: scriptCodeID, + UserID: userID, + Score: result.Score, + SubScores: string(subScoresJSON), + HitSignals: string(hitsJSON), + Status: similarity_entity.ReviewStatusPending, + Createtime: now, + Updatetime: now, + } + if err := similarity_repo.IntegrityReview().Upsert(ctx, row); err != nil { + log.Error("integrity record: upsert failed", zap.Error(err)) + return err + } + log.Info("integrity warning recorded", zap.Int("hit_count", len(result.HitSignals))) + return nil +} + +// signalDescriptions maps signal names to human-readable Chinese descriptions. +var signalDescriptions = map[string]string{ + "avg_line_length": "平均行长度过长(代码可能被压缩为少量长行)", + "max_line_length": "最大行长度过长(存在超长代码行)", + "whitespace_ratio": "空白字符比例过低(代码缺少正常的空格和缩进)", + "comment_ratio": "注释比例过低(代码几乎没有注释)", + "single_char_ident_ratio": "单字符变量名比例过高(变量名被缩短为单个字符)", + "hex_ident_ratio": "十六进制变量名比例过高(使用了 _0x 开头的混淆变量名)", + "large_string_array": "检测到大型字符串数组(常见于混淆工具的字符串表)", + "dean_edwards_packer": "检测到 Dean Edwards 打包器", + "eval_density": "eval/动态执行调用密度过高", +} + +// BuildUserMessage formats the user-facing rejection message described in +// spec §10.7. Used by script_svc to enrich SimilarityIntegrityRejected. +func (r *IntegrityResult) BuildUserMessage() string { + parts := make([]string, 0, len(r.HitSignals)) + for _, h := range r.HitSignals { + if h.Value < 0.5 { + continue + } + if desc, ok := signalDescriptions[h.Name]; ok { + parts = append(parts, desc) + } else { + parts = append(parts, h.Name) + } + } + return fmt.Sprintf("代码未通过完整性检查(综合评分 %.2f)。命中信号:%s。ScriptList 要求所有脚本以可读源代码形式发布。如为误判,请通过站点 FAQ 的\"管理员联系方式\"申请豁免。", + r.Score, strings.Join(parts, "、")) +} + +// Convenience accessors so script_svc can avoid touching configs directly. +func IntegrityEnabled() bool { + return configs.Similarity().IntegrityEnabled +} + +func IntegrityWarnThreshold() float64 { + return configs.Similarity().IntegrityWarnThreshold +} + +func IntegrityBlockThreshold() float64 { + return configs.Similarity().IntegrityBlockThreshold +} diff --git a/internal/service/similarity_svc/integrity_bench_test.go b/internal/service/similarity_svc/integrity_bench_test.go new file mode 100644 index 0000000..d51a1fe --- /dev/null +++ b/internal/service/similarity_svc/integrity_bench_test.go @@ -0,0 +1,59 @@ +package similarity_svc + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func readBenchdata(b *testing.B, relPath string) string { + b.Helper() + data, err := os.ReadFile(filepath.Join("testdata", relPath)) //nolint:gosec // test fixture path + if err != nil { + b.Fatalf("read %s: %v", relPath, err) + } + return string(data) +} + +func bigSample(b *testing.B, seedPath string, size int) string { + b.Helper() + seed := readBenchdata(b, seedPath) + var sb strings.Builder + sb.Grow(size + len(seed)) + for sb.Len() < size { + sb.WriteString(seed) + sb.WriteByte('\n') + } + return sb.String() +} + +func BenchmarkIntegrityCheck_Obfuscated_1MB(b *testing.B) { + svc := NewIntegritySvc() + code := bigSample(b, "integrity/obfuscated/obfuscator_io_level4.js", 1024*1024) + ctx := context.Background() + b.SetBytes(int64(len(code))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = svc.Check(ctx, code) + } +} + +func BenchmarkIntegrityCheck_Plain_256KB(b *testing.B) { + svc := NewIntegritySvc() + seed := readBenchdata(b, "integrity/normal/plain_userscript.js") + var sb strings.Builder + sb.Grow(256 * 1024) + for sb.Len() < 256*1024 { + sb.WriteString(seed) + sb.WriteByte('\n') + } + code := sb.String() + ctx := context.Background() + b.SetBytes(int64(len(code))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = svc.Check(ctx, code) + } +} diff --git a/internal/service/similarity_svc/integrity_signals.go b/internal/service/similarity_svc/integrity_signals.go new file mode 100644 index 0000000..df7c2a8 --- /dev/null +++ b/internal/service/similarity_svc/integrity_signals.go @@ -0,0 +1,314 @@ +package similarity_svc + +import ( + "regexp" + "strings" + "unicode" +) + +// All signals return a normalized score in [0, 1] where 1 means the signal is +// fully triggered (suspicious) and 0 means clean. Triggers come from spec §10.2. + +type signalPhase string + +const ( + phaseSync signalPhase = "sync" + phaseAsync signalPhase = "async" +) + +// codeFeatures holds everything derived from a single pass over the source so +// multiple signals can share the work. Expensive artifacts (identifier list, +// comment stats) are computed lazily on first use. +type codeFeatures struct { + code string + + // A-category primitives, from a single rune scan. + nonNLBytes int // len(code) - newline count + lineCount int // strings.Count(code, "\n") + 1 (mirrors strings.Split) + maxLineLen int // byte length of longest line (no trailing newline) + whitespaceCount int // runes for which unicode.IsSpace is true + + // Cached idents list (non-keyword JS identifiers with string literals stripped). + identsComputed bool + idents []string + + // Cached comment byte count (block comments via regex + lines matching ^\s*//). + commentComputed bool + commentChars int +} + +func newCodeFeatures(code string) *codeFeatures { + f := &codeFeatures{code: code} + if len(code) == 0 { + return f + } + + nlCount := 0 + lineStart := 0 + maxLineLen := 0 + wsCount := 0 + + for i, r := range code { + if unicode.IsSpace(r) { + wsCount++ + } + if r == '\n' { + nlCount++ + if lineLen := i - lineStart; lineLen > maxLineLen { + maxLineLen = lineLen + } + lineStart = i + 1 // '\n' is always 1 byte + } + } + if lastLineLen := len(code) - lineStart; lastLineLen > maxLineLen { + maxLineLen = lastLineLen + } + + f.nonNLBytes = len(code) - nlCount + f.lineCount = nlCount + 1 + f.maxLineLen = maxLineLen + f.whitespaceCount = wsCount + return f +} + +func (f *codeFeatures) getIdents() []string { + if !f.identsComputed { + f.idents = collectIdents(f.code) + f.identsComputed = true + } + return f.idents +} + +func (f *codeFeatures) getCommentChars() int { + if !f.commentComputed { + f.commentChars = countCommentChars(f.code) + f.commentComputed = true + } + return f.commentChars +} + +// ----- Category A: minification ----- + +func featAvgLineLength(f *codeFeatures) float64 { + if f.lineCount == 0 { + return 0 + } + avg := float64(f.nonNLBytes) / float64(f.lineCount) + // tuned: divisor lowered 200.0 → 100.0 so typical minifier one-liners saturate. + return clamp01(avg / 100.0) +} + +func featMaxLineLength(f *codeFeatures) float64 { + // tuned: threshold lowered 500/1500 → 200/700 — even short obfuscated snippets + // have lines well over 200 chars, so this fires on the tiny encoded tests. + if f.maxLineLen < 200 { + return 0 + } + return clamp01(float64(f.maxLineLen-200) / 500.0) +} + +func featWhitespaceRatio(f *codeFeatures) float64 { + if len(f.code) == 0 { + return 0 + } + ratio := float64(f.whitespaceCount) / float64(len(f.code)) + if ratio >= 0.05 { + return 0 + } + return 1.0 - (ratio / 0.05) +} + +var commentLineRe = regexp.MustCompile(`(?m)^[\t\f\r ]*//`) +var blockCommentRe = regexp.MustCompile(`(?s)/\*.*?\*/`) + +// countCommentChars mirrors the original signalCommentRatio accounting: sum of +// block-comment bytes plus the full byte length of any line whose first +// non-whitespace chars are `//`. Done without splitting the string. +func countCommentChars(code string) int { + if len(code) == 0 { + return 0 + } + chars := 0 + for _, loc := range blockCommentRe.FindAllStringIndex(code, -1) { + chars += loc[1] - loc[0] + } + // Walk line-comment regex matches and add the byte length of the enclosing line. + for _, loc := range commentLineRe.FindAllStringIndex(code, -1) { + lineStart := loc[0] + // Find end of this line. + lineEnd := lineStart + for lineEnd < len(code) && code[lineEnd] != '\n' { + lineEnd++ + } + chars += lineEnd - lineStart + } + return chars +} + +func featCommentRatio(f *codeFeatures) float64 { + if len(f.code) == 0 { + return 1 + } + ratio := float64(f.getCommentChars()) / float64(len(f.code)) + if ratio >= 0.01 { + return 0 + } + return 1.0 - (ratio / 0.01) +} + +// ----- Category B: identifier obfuscation ----- + +var identRe = regexp.MustCompile(`[A-Za-z_$][A-Za-z0-9_$]*`) +var hexIdentRe = regexp.MustCompile(`^_0x[0-9a-f]+$`) + +// tuned: stringLiteralRe is used to strip JS string contents before collecting +// identifiers, so arrays of `\xNN` escape sequences don't dilute the identifier +// pool with fake `xNN` matches (critical for obfuscator.io level 4 style files). +var stringLiteralRe = regexp.MustCompile(`"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'`) + +var jsKeywords = map[string]bool{ + "var": true, "let": true, "const": true, "function": true, "return": true, + "if": true, "else": true, "for": true, "while": true, "do": true, "in": true, + "of": true, "true": true, "false": true, "null": true, "undefined": true, + "new": true, "this": true, "typeof": true, "instanceof": true, "void": true, + "delete": true, "throw": true, "try": true, "catch": true, "finally": true, + "break": true, "continue": true, "switch": true, "case": true, "default": true, + "class": true, "extends": true, "super": true, "import": true, "export": true, + "from": true, "as": true, "async": true, "await": true, "yield": true, +} + +func collectIdents(code string) []string { + stripped := stringLiteralRe.ReplaceAllString(code, `""`) + matches := identRe.FindAllString(stripped, -1) + out := make([]string, 0, len(matches)) + for _, m := range matches { + if !jsKeywords[m] { + out = append(out, m) + } + } + return out +} + +func featSingleCharIdentRatio(f *codeFeatures) float64 { + idents := f.getIdents() + if len(idents) == 0 { + return 0 + } + short := 0 + for _, id := range idents { + if len(id) == 1 { + short++ + } + } + ratio := float64(short) / float64(len(idents)) + // tuned: divisor lowered 0.6 → 0.4 so encoded snippets with ~50% single-char + // identifiers saturate. + return clamp01(ratio / 0.4) +} + +func featHexIdentRatio(f *codeFeatures) float64 { + idents := f.getIdents() + if len(idents) == 0 { + return 0 + } + hex := 0 + for _, id := range idents { + if hexIdentRe.MatchString(id) { + hex++ + } + } + ratio := float64(hex) / float64(len(idents)) + return clamp01(ratio / 0.2) +} + +// ----- Category C: string encoding ----- + +var bigArrayRe = regexp.MustCompile(`(?s)(?:var|let|const)\s+\w+\s*=\s*\[("[^"]*"\s*,\s*){50,}`) + +// tuned: added a sister pattern that matches any hex-identifier (`_0xNAME`) +// string array with 4+ entries — classic obfuscator.io string table — even when +// the table is small enough to evade the {50,} general detector. +var hexStringArrayRe = regexp.MustCompile(`(?s)(?:var|let|const)\s+_0x[0-9a-fA-F]+\s*=\s*\[(?:(?:"[^"]*"|'[^']*')\s*,\s*){3,}(?:"[^"]*"|'[^']*')\s*\]`) + +func featLargeStringArray(f *codeFeatures) float64 { + if bigArrayRe.MatchString(f.code) || hexStringArrayRe.MatchString(f.code) { + return 1 + } + return 0 +} + +// ----- Category D: dynamic execution + known packers ----- + +var deanEdwardsRe = regexp.MustCompile(`eval\(function\(p,a,c,k,e,[dr]\)`) + +func featDeanEdwardsPacker(f *codeFeatures) float64 { + if deanEdwardsRe.MatchString(f.code) { + return 1 + } + return 0 +} + +// tuned: obfuscatorDynLookupRe counts obfuscator.io-style indirect string-table +// lookups (`_0x('0x')`) as dynamic execution, so Cat D fires on +// obfuscated code that lacks raw `eval(`. +var obfuscatorDynLookupRe = regexp.MustCompile(`_0x[0-9a-fA-F]+\(['"]0x[0-9a-fA-F]+['"]\)`) + +func featEvalDensity(f *codeFeatures) float64 { + if f.lineCount == 0 { + return 0 + } + evals := strings.Count(f.code, "eval(") + strings.Count(f.code, "new Function(") + evals += len(obfuscatorDynLookupRe.FindAllStringIndex(f.code, -1)) + per1k := float64(evals) / (float64(f.lineCount) / 1000.0) + // tuned: divisor lowered 5.0 → 2.0 so a handful of dynamic calls in a short + // file is enough to saturate the signal. + return clamp01(per1k / 2.0) +} + +func clamp01(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +// ----- Back-compat wrappers (used by per-signal unit tests) ----- + +func signalAvgLineLength(code string) float64 { + return featAvgLineLength(newCodeFeatures(code)) +} + +func signalMaxLineLength(code string) float64 { + return featMaxLineLength(newCodeFeatures(code)) +} + +func signalWhitespaceRatio(code string) float64 { + return featWhitespaceRatio(newCodeFeatures(code)) +} + +func signalCommentRatio(code string) float64 { + return featCommentRatio(newCodeFeatures(code)) +} + +func signalSingleCharIdentRatio(code string) float64 { + return featSingleCharIdentRatio(newCodeFeatures(code)) +} + +func signalHexIdentRatio(code string) float64 { + return featHexIdentRatio(newCodeFeatures(code)) +} + +func signalLargeStringArray(code string) float64 { + return featLargeStringArray(newCodeFeatures(code)) +} + +func signalDeanEdwardsPacker(code string) float64 { + return featDeanEdwardsPacker(newCodeFeatures(code)) +} + +func signalEvalDensity(code string) float64 { + return featEvalDensity(newCodeFeatures(code)) +} diff --git a/internal/service/similarity_svc/integrity_signals_test.go b/internal/service/similarity_svc/integrity_signals_test.go new file mode 100644 index 0000000..ee7fc22 --- /dev/null +++ b/internal/service/similarity_svc/integrity_signals_test.go @@ -0,0 +1,84 @@ +package similarity_svc + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSignalAvgLineLength(t *testing.T) { + short := "var a = 1;\nvar b = 2;\nvar c = 3;\n" + assert.InDelta(t, 0.0, signalAvgLineLength(short), 0.5) + + long := strings.Repeat("x", 400) + "\n" + assert.Equal(t, 1.0, signalAvgLineLength(long)) +} + +func TestSignalMaxLineLength(t *testing.T) { + short := "abc\ndef\n" + assert.Equal(t, 0.0, signalMaxLineLength(short)) + + long := "abc\n" + strings.Repeat("x", 1500) + "\n" + assert.Equal(t, 1.0, signalMaxLineLength(long)) +} + +func TestSignalWhitespaceRatio_LowIsBad(t *testing.T) { + dense := strings.Repeat("a", 100) + assert.Equal(t, 1.0, signalWhitespaceRatio(dense)) + + loose := "a b c d e f g h i j " + strings.Repeat("\n", 50) + assert.Less(t, signalWhitespaceRatio(loose), 1.0) +} + +func TestSignalCommentRatio_LowIsBad(t *testing.T) { + none := "var a = 1; var b = 2;" + assert.Equal(t, 1.0, signalCommentRatio(none)) + + heavy := "// hello\n// world\n// comments\nvar a;\n// more\n// even more\n" + assert.Less(t, signalCommentRatio(heavy), 1.0) +} + +func TestSignalSingleCharIdentRatio(t *testing.T) { + verbose := "function getUserName(userId, accountType) { return userId + accountType; }" + assert.Less(t, signalSingleCharIdentRatio(verbose), 0.5) + + mangled := "function a(b,c){return b+c;}" + assert.Greater(t, signalSingleCharIdentRatio(mangled), 0.6) +} + +func TestSignalHexIdentRatio_ObfuscatorIo(t *testing.T) { + clean := "var name = 1; var greeting = 2;" + assert.Equal(t, 0.0, signalHexIdentRatio(clean)) + + obf := "var _0xabc = 1; var _0xdef = 2; var _0x123 = 3; var _0x456 = 4; var _0x789 = 5;" + assert.Equal(t, 1.0, signalHexIdentRatio(obf)) +} + +func TestSignalLargeStringArray(t *testing.T) { + clean := `var x = ["a", "b", "c"];` + assert.Equal(t, 0.0, signalLargeStringArray(clean)) + + bigArrayItems := make([]string, 80) + for i := range bigArrayItems { + bigArrayItems[i] = `"x"` + } + obf := "var _0x1234 = [" + strings.Join(bigArrayItems, ",") + "];" + assert.Equal(t, 1.0, signalLargeStringArray(obf)) +} + +func TestSignalDeanEdwardsPacker(t *testing.T) { + clean := "function foo() { return 1; }" + assert.Equal(t, 0.0, signalDeanEdwardsPacker(clean)) + + packed := "eval(function(p,a,c,k,e,d){return 'whatever';}('payload',1,2,'a|b'.split('|'),0,{}))" + assert.Equal(t, 1.0, signalDeanEdwardsPacker(packed)) +} + +func TestSignalEvalDensity(t *testing.T) { + clean := strings.Repeat("var a = 1;\n", 1000) + assert.Less(t, signalEvalDensity(clean), 1.0) + + heavy := strings.Repeat("eval(x);\n", 50) + strings.Repeat("var a;\n", 50) + assert.Equal(t, 1.0, signalEvalDensity(heavy)) +} diff --git a/internal/service/similarity_svc/integrity_test.go b/internal/service/similarity_svc/integrity_test.go new file mode 100644 index 0000000..64c457a --- /dev/null +++ b/internal/service/similarity_svc/integrity_test.go @@ -0,0 +1,106 @@ +package similarity_svc + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func loadIntegrityTestdata(t *testing.T, rel string) string { + t.Helper() + return loadTestdata(t, "integrity/"+rel) +} + +func TestIntegrity_NormalCode_Passes(t *testing.T) { + svc := NewIntegritySvc() + r := svc.Check(context.Background(), loadIntegrityTestdata(t, "normal/plain_userscript.js")) + t.Logf("normal/plain_userscript.js score=%v sub=%v", r.Score, r.SubScores) + assert.Less(t, r.Score, 0.3, "score=%v", r.Score) +} + +func TestIntegrity_EmbeddedSmallLib_Passes(t *testing.T) { + svc := NewIntegritySvc() + r := svc.Check(context.Background(), loadIntegrityTestdata(t, "normal/embedded_small_lib.js")) + t.Logf("normal/embedded_small_lib.js score=%v sub=%v", r.Score, r.SubScores) + assert.Less(t, r.Score, 0.5, "score=%v", r.Score) +} + +func TestIntegrity_Minified_InWarnZone(t *testing.T) { + svc := NewIntegritySvc() + for _, name := range []string{"minified/uglify_output.js", "minified/terser_output.js"} { + t.Run(name, func(t *testing.T) { + r := svc.Check(context.Background(), loadIntegrityTestdata(t, name)) + t.Logf("%s score=%v sub=%v", name, r.Score, r.SubScores) + assert.GreaterOrEqual(t, r.Score, 0.5, "score=%v", r.Score) + assert.Less(t, r.Score, 0.8, "score=%v", r.Score) + }) + } +} + +func TestIntegrity_Obfuscated_Blocks(t *testing.T) { + svc := NewIntegritySvc() + for _, name := range []string{ + "obfuscated/obfuscator_io_level1.js", + "obfuscated/obfuscator_io_level4.js", + } { + t.Run(name, func(t *testing.T) { + r := svc.Check(context.Background(), loadIntegrityTestdata(t, name)) + t.Logf("%s score=%v sub=%v", name, r.Score, r.SubScores) + assert.GreaterOrEqual(t, r.Score, 0.8, "score=%v", r.Score) + }) + } +} + +func TestIntegrity_DeanEdwardsPacker_Blocks(t *testing.T) { + svc := NewIntegritySvc() + r := svc.Check(context.Background(), loadIntegrityTestdata(t, "packed/dean_edwards_packer.js")) + t.Logf("packed/dean_edwards_packer.js score=%v sub=%v", r.Score, r.SubScores) + assert.GreaterOrEqual(t, r.Score, 0.8, "score=%v", r.Score) +} + +func TestIntegrity_CheckFast_NormalCode_Passes(t *testing.T) { + svc := NewIntegritySvc() + r := svc.CheckFast(context.Background(), loadIntegrityTestdata(t, "normal/plain_userscript.js")) + t.Logf("CheckFast normal score=%v sub=%v", r.Score, r.SubScores) + assert.Less(t, r.Score, 0.3) + assert.True(t, r.Partial) + assert.False(t, r.KnownPacker) +} + +func TestIntegrity_CheckFast_DeanEdwardsPacker_Blocks(t *testing.T) { + svc := NewIntegritySvc() + r := svc.CheckFast(context.Background(), loadIntegrityTestdata(t, "packed/dean_edwards_packer.js")) + t.Logf("CheckFast dean_edwards score=%v known_packer=%v", r.Score, r.KnownPacker) + assert.GreaterOrEqual(t, r.Score, 0.8) + assert.True(t, r.KnownPacker) + assert.True(t, r.Partial) +} + +func TestIntegrity_CheckFast_NoExpensiveSignals(t *testing.T) { + svc := NewIntegritySvc() + for _, name := range []string{ + "normal/plain_userscript.js", + "obfuscated/obfuscator_io_level4.js", + } { + t.Run(name, func(t *testing.T) { + code := loadIntegrityTestdata(t, name) + r := svc.CheckFast(context.Background(), code) + for _, h := range r.HitSignals { + for _, sig := range allSignals { + if sig.Name == h.Name { + assert.Equal(t, phaseSync, sig.Phase, "CheckFast should not run async signal: %s", h.Name) + } + } + } + }) + } +} + +func TestIntegrity_BuildUserMessage_HasSignals(t *testing.T) { + svc := NewIntegritySvc() + r := svc.Check(context.Background(), loadIntegrityTestdata(t, "obfuscated/obfuscator_io_level4.js")) + msg := r.BuildUserMessage() + assert.Contains(t, msg, "完整性检查") + assert.NotEmpty(t, r.HitSignals) +} diff --git a/internal/service/similarity_svc/match_segments.go b/internal/service/similarity_svc/match_segments.go new file mode 100644 index 0000000..33392d5 --- /dev/null +++ b/internal/service/similarity_svc/match_segments.go @@ -0,0 +1,146 @@ +package similarity_svc + +import ( + "context" + "sort" + + "github.com/cago-frame/cago/pkg/logger" + api "github.com/scriptscat/scriptlist/internal/api/similarity" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + "go.uber.org/zap" +) + +// kGramByteSize is the k-gram length used by winnowing. Positions returned +// by ES are start offsets, so each matched fingerprint covers [pos, pos+5). +const kGramByteSize = 5 + +// mergeGapBytes merges two adjacent positions whose gap is strictly less than +// this value (spec §5.4). +const mergeGapBytes = 10 + +// BuildMatchSegments assembles match segments for a pair's detail view. +// +// Phase 2 does not persist the common-fingerprint list (pair.MatchedFp is +// always empty), so we derive matches live by pulling both scripts' full +// fingerprint position sets from ES and intersecting by fingerprint hash. +// The live derivation costs two ES queries per detail-page view — acceptable +// at Phase 3 scale since the detail page is low-frequency. +// +// Returns (nil, nil) if either side has no fingerprint index row (pre-scan +// or soft-deleted script) — the detail page will simply show un-highlighted code. +func BuildMatchSegments(ctx context.Context, pair *similarity_entity.SimilarPair) ([]api.MatchSegment, error) { + if pair == nil { + return nil, nil + } + log := logger.Ctx(ctx).With( + zap.Int64("script_a_id", pair.ScriptAID), + zap.Int64("script_b_id", pair.ScriptBID), + ) + aFP, err := similarity_repo.Fingerprint().FindByScriptID(ctx, pair.ScriptAID) + if err != nil { + log.Error("match segments: load A fingerprint row failed", zap.Error(err)) + return nil, err + } + if aFP == nil { + log.Info("match segments: A has no fingerprint row, returning empty") + return nil, nil + } + bFP, err := similarity_repo.Fingerprint().FindByScriptID(ctx, pair.ScriptBID) + if err != nil { + log.Error("match segments: load B fingerprint row failed", zap.Error(err)) + return nil, err + } + if bFP == nil { + log.Info("match segments: B has no fingerprint row, returning empty") + return nil, nil + } + aPos, err := similarity_repo.FingerprintES().FindAllFingerprintPositions(ctx, pair.ScriptAID, aFP.BatchID) + if err != nil { + log.Error("match segments: load A positions from ES failed", + zap.Int64("batch_id", aFP.BatchID), zap.Error(err)) + return nil, err + } + bPos, err := similarity_repo.FingerprintES().FindAllFingerprintPositions(ctx, pair.ScriptBID, bFP.BatchID) + if err != nil { + log.Error("match segments: load B positions from ES failed", + zap.Int64("batch_id", bFP.BatchID), zap.Error(err)) + return nil, err + } + segs := mergeMatchSegments(aPos, bPos, mergeGapBytes) + log.Info("match segments built", + zap.Int("a_positions", len(aPos)), + zap.Int("b_positions", len(bPos)), + zap.Int("segments", len(segs)), + ) + return segs, nil +} + +// mergeMatchSegments intersects two sides' (fingerprint, position) sets by +// fingerprint hash, pairs them up (min-length matching when the same +// fingerprint appears multiple times on a side), sorts by A's position, and +// merges adjacent runs whose gap is strictly less than mergeGap on BOTH sides. +// Each returned segment covers [pos, pos+kGramByteSize) on both sides. +func mergeMatchSegments(aPos, bPos []similarity_repo.FingerprintPosition, mergeGap int) []api.MatchSegment { + if len(aPos) == 0 || len(bPos) == 0 { + return nil + } + aByFP := map[string][]int{} + for _, p := range aPos { + aByFP[p.Fingerprint] = append(aByFP[p.Fingerprint], p.Position) + } + bByFP := map[string][]int{} + for _, p := range bPos { + bByFP[p.Fingerprint] = append(bByFP[p.Fingerprint], p.Position) + } + + type rawPair struct{ a, b int } + var raw []rawPair + for fp, as := range aByFP { + bs, ok := bByFP[fp] + if !ok { + continue + } + // Sort both for deterministic pairing. + sort.Ints(as) + sort.Ints(bs) + n := len(as) + if len(bs) < n { + n = len(bs) + } + for i := 0; i < n; i++ { + raw = append(raw, rawPair{a: as[i], b: bs[i]}) + } + } + if len(raw) == 0 { + return nil + } + sort.Slice(raw, func(i, j int) bool { + if raw[i].a != raw[j].a { + return raw[i].a < raw[j].a + } + return raw[i].b < raw[j].b + }) + + segs := []api.MatchSegment{{ + AStart: raw[0].a, AEnd: raw[0].a + kGramByteSize, + BStart: raw[0].b, BEnd: raw[0].b + kGramByteSize, + }} + for _, p := range raw[1:] { + last := &segs[len(segs)-1] + if p.a-last.AEnd < mergeGap && p.b-last.BEnd < mergeGap { + if p.a+kGramByteSize > last.AEnd { + last.AEnd = p.a + kGramByteSize + } + if p.b+kGramByteSize > last.BEnd { + last.BEnd = p.b + kGramByteSize + } + continue + } + segs = append(segs, api.MatchSegment{ + AStart: p.a, AEnd: p.a + kGramByteSize, + BStart: p.b, BEnd: p.b + kGramByteSize, + }) + } + return segs +} diff --git a/internal/service/similarity_svc/match_segments_test.go b/internal/service/similarity_svc/match_segments_test.go new file mode 100644 index 0000000..3e1b34e --- /dev/null +++ b/internal/service/similarity_svc/match_segments_test.go @@ -0,0 +1,51 @@ +package similarity_svc + +import ( + "testing" + + api "github.com/scriptscat/scriptlist/internal/api/similarity" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + "github.com/stretchr/testify/assert" +) + +func TestMergeAdjacentPositions_MergesWithinThreshold(t *testing.T) { + aPositions := []similarity_repo.FingerprintPosition{ + {Fingerprint: "f1", Position: 100}, + {Fingerprint: "f2", Position: 105}, // adjacent + {Fingerprint: "f3", Position: 500}, // gap + } + bPositions := []similarity_repo.FingerprintPosition{ + {Fingerprint: "f1", Position: 200}, + {Fingerprint: "f2", Position: 205}, + {Fingerprint: "f3", Position: 700}, + } + segs := mergeMatchSegments(aPositions, bPositions, 5) + assert.Equal(t, []api.MatchSegment{ + {AStart: 100, AEnd: 110, BStart: 200, BEnd: 210}, + {AStart: 500, AEnd: 505, BStart: 700, BEnd: 705}, + }, segs) +} + +func TestMergeAdjacentPositions_HandlesEmpty(t *testing.T) { + got := mergeMatchSegments(nil, nil, 10) + assert.Empty(t, got) +} + +func TestMergeAdjacentPositions_IntersectsByFingerprint(t *testing.T) { + // A has f1, f2, f4 — B has f1, f3, f4. Only f1 and f4 are common. + aPositions := []similarity_repo.FingerprintPosition{ + {Fingerprint: "f1", Position: 10}, + {Fingerprint: "f2", Position: 20}, + {Fingerprint: "f4", Position: 300}, + } + bPositions := []similarity_repo.FingerprintPosition{ + {Fingerprint: "f1", Position: 100}, + {Fingerprint: "f3", Position: 200}, + {Fingerprint: "f4", Position: 400}, + } + segs := mergeMatchSegments(aPositions, bPositions, 5) + assert.Equal(t, []api.MatchSegment{ + {AStart: 10, AEnd: 15, BStart: 100, BEnd: 105}, + {AStart: 300, AEnd: 305, BStart: 400, BEnd: 405}, + }, segs) +} diff --git a/internal/service/similarity_svc/mock/admin.go b/internal/service/similarity_svc/mock/admin.go new file mode 100644 index 0000000..74cdca6 --- /dev/null +++ b/internal/service/similarity_svc/mock/admin.go @@ -0,0 +1,327 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./admin.go +// +// Generated by this command: +// +// mockgen -source=./admin.go -destination=./mock/admin.go +// + +// Package mock_similarity_svc is a generated GoMock package. +package mock_similarity_svc + +import ( + context "context" + reflect "reflect" + + similarity "github.com/scriptscat/scriptlist/internal/api/similarity" + gomock "go.uber.org/mock/gomock" +) + +// MockAdminSvc is a mock of AdminSvc interface. +type MockAdminSvc struct { + ctrl *gomock.Controller + recorder *MockAdminSvcMockRecorder + isgomock struct{} +} + +// MockAdminSvcMockRecorder is the mock recorder for MockAdminSvc. +type MockAdminSvcMockRecorder struct { + mock *MockAdminSvc +} + +// NewMockAdminSvc creates a new mock instance. +func NewMockAdminSvc(ctrl *gomock.Controller) *MockAdminSvc { + mock := &MockAdminSvc{ctrl: ctrl} + mock.recorder = &MockAdminSvcMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAdminSvc) EXPECT() *MockAdminSvcMockRecorder { + return m.recorder +} + +// AddIntegrityWhitelist mocks base method. +func (m *MockAdminSvc) AddIntegrityWhitelist(ctx context.Context, req *similarity.AddIntegrityWhitelistRequest) (*similarity.AddIntegrityWhitelistResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddIntegrityWhitelist", ctx, req) + ret0, _ := ret[0].(*similarity.AddIntegrityWhitelistResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddIntegrityWhitelist indicates an expected call of AddIntegrityWhitelist. +func (mr *MockAdminSvcMockRecorder) AddIntegrityWhitelist(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddIntegrityWhitelist", reflect.TypeOf((*MockAdminSvc)(nil).AddIntegrityWhitelist), ctx, req) +} + +// AddPairWhitelist mocks base method. +func (m *MockAdminSvc) AddPairWhitelist(ctx context.Context, req *similarity.AddPairWhitelistRequest) (*similarity.AddPairWhitelistResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddPairWhitelist", ctx, req) + ret0, _ := ret[0].(*similarity.AddPairWhitelistResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddPairWhitelist indicates an expected call of AddPairWhitelist. +func (mr *MockAdminSvcMockRecorder) AddPairWhitelist(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddPairWhitelist", reflect.TypeOf((*MockAdminSvc)(nil).AddPairWhitelist), ctx, req) +} + +// GetBackfillStatus mocks base method. +func (m *MockAdminSvc) GetBackfillStatus(ctx context.Context, req *similarity.GetBackfillStatusRequest) (*similarity.GetBackfillStatusResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBackfillStatus", ctx, req) + ret0, _ := ret[0].(*similarity.GetBackfillStatusResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBackfillStatus indicates an expected call of GetBackfillStatus. +func (mr *MockAdminSvcMockRecorder) GetBackfillStatus(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBackfillStatus", reflect.TypeOf((*MockAdminSvc)(nil).GetBackfillStatus), ctx, req) +} + +// GetEvidencePair mocks base method. +func (m *MockAdminSvc) GetEvidencePair(ctx context.Context, req *similarity.GetEvidencePairRequest) (*similarity.GetEvidencePairResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetEvidencePair", ctx, req) + ret0, _ := ret[0].(*similarity.GetEvidencePairResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetEvidencePair indicates an expected call of GetEvidencePair. +func (mr *MockAdminSvcMockRecorder) GetEvidencePair(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEvidencePair", reflect.TypeOf((*MockAdminSvc)(nil).GetEvidencePair), ctx, req) +} + +// GetIntegrityReview mocks base method. +func (m *MockAdminSvc) GetIntegrityReview(ctx context.Context, req *similarity.GetIntegrityReviewRequest) (*similarity.GetIntegrityReviewResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetIntegrityReview", ctx, req) + ret0, _ := ret[0].(*similarity.GetIntegrityReviewResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetIntegrityReview indicates an expected call of GetIntegrityReview. +func (mr *MockAdminSvcMockRecorder) GetIntegrityReview(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIntegrityReview", reflect.TypeOf((*MockAdminSvc)(nil).GetIntegrityReview), ctx, req) +} + +// GetPairDetail mocks base method. +func (m *MockAdminSvc) GetPairDetail(ctx context.Context, req *similarity.GetPairDetailRequest) (*similarity.GetPairDetailResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPairDetail", ctx, req) + ret0, _ := ret[0].(*similarity.GetPairDetailResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPairDetail indicates an expected call of GetPairDetail. +func (mr *MockAdminSvcMockRecorder) GetPairDetail(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPairDetail", reflect.TypeOf((*MockAdminSvc)(nil).GetPairDetail), ctx, req) +} + +// ListIntegrityReviews mocks base method. +func (m *MockAdminSvc) ListIntegrityReviews(ctx context.Context, req *similarity.ListIntegrityReviewsRequest) (*similarity.ListIntegrityReviewsResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListIntegrityReviews", ctx, req) + ret0, _ := ret[0].(*similarity.ListIntegrityReviewsResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListIntegrityReviews indicates an expected call of ListIntegrityReviews. +func (mr *MockAdminSvcMockRecorder) ListIntegrityReviews(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListIntegrityReviews", reflect.TypeOf((*MockAdminSvc)(nil).ListIntegrityReviews), ctx, req) +} + +// ListIntegrityWhitelist mocks base method. +func (m *MockAdminSvc) ListIntegrityWhitelist(ctx context.Context, req *similarity.ListIntegrityWhitelistRequest) (*similarity.ListIntegrityWhitelistResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListIntegrityWhitelist", ctx, req) + ret0, _ := ret[0].(*similarity.ListIntegrityWhitelistResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListIntegrityWhitelist indicates an expected call of ListIntegrityWhitelist. +func (mr *MockAdminSvcMockRecorder) ListIntegrityWhitelist(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListIntegrityWhitelist", reflect.TypeOf((*MockAdminSvc)(nil).ListIntegrityWhitelist), ctx, req) +} + +// ListPairWhitelist mocks base method. +func (m *MockAdminSvc) ListPairWhitelist(ctx context.Context, req *similarity.ListPairWhitelistRequest) (*similarity.ListPairWhitelistResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListPairWhitelist", ctx, req) + ret0, _ := ret[0].(*similarity.ListPairWhitelistResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListPairWhitelist indicates an expected call of ListPairWhitelist. +func (mr *MockAdminSvcMockRecorder) ListPairWhitelist(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPairWhitelist", reflect.TypeOf((*MockAdminSvc)(nil).ListPairWhitelist), ctx, req) +} + +// ListPairs mocks base method. +func (m *MockAdminSvc) ListPairs(ctx context.Context, req *similarity.ListPairsRequest) (*similarity.ListPairsResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListPairs", ctx, req) + ret0, _ := ret[0].(*similarity.ListPairsResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListPairs indicates an expected call of ListPairs. +func (mr *MockAdminSvcMockRecorder) ListPairs(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListPairs", reflect.TypeOf((*MockAdminSvc)(nil).ListPairs), ctx, req) +} + +// ListParseFailures mocks base method. +func (m *MockAdminSvc) ListParseFailures(ctx context.Context, req *similarity.ListParseFailuresRequest) (*similarity.ListParseFailuresResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListParseFailures", ctx, req) + ret0, _ := ret[0].(*similarity.ListParseFailuresResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListParseFailures indicates an expected call of ListParseFailures. +func (mr *MockAdminSvcMockRecorder) ListParseFailures(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListParseFailures", reflect.TypeOf((*MockAdminSvc)(nil).ListParseFailures), ctx, req) +} + +// ListSuspects mocks base method. +func (m *MockAdminSvc) ListSuspects(ctx context.Context, req *similarity.ListSuspectsRequest) (*similarity.ListSuspectsResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListSuspects", ctx, req) + ret0, _ := ret[0].(*similarity.ListSuspectsResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListSuspects indicates an expected call of ListSuspects. +func (mr *MockAdminSvcMockRecorder) ListSuspects(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListSuspects", reflect.TypeOf((*MockAdminSvc)(nil).ListSuspects), ctx, req) +} + +// ManualScan mocks base method. +func (m *MockAdminSvc) ManualScan(ctx context.Context, req *similarity.ManualScanRequest) (*similarity.ManualScanResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ManualScan", ctx, req) + ret0, _ := ret[0].(*similarity.ManualScanResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ManualScan indicates an expected call of ManualScan. +func (mr *MockAdminSvcMockRecorder) ManualScan(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ManualScan", reflect.TypeOf((*MockAdminSvc)(nil).ManualScan), ctx, req) +} + +// RefreshStopFp mocks base method. +func (m *MockAdminSvc) RefreshStopFp(ctx context.Context, req *similarity.RefreshStopFpRequest) (*similarity.RefreshStopFpResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RefreshStopFp", ctx, req) + ret0, _ := ret[0].(*similarity.RefreshStopFpResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RefreshStopFp indicates an expected call of RefreshStopFp. +func (mr *MockAdminSvcMockRecorder) RefreshStopFp(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RefreshStopFp", reflect.TypeOf((*MockAdminSvc)(nil).RefreshStopFp), ctx, req) +} + +// RemoveIntegrityWhitelist mocks base method. +func (m *MockAdminSvc) RemoveIntegrityWhitelist(ctx context.Context, req *similarity.RemoveIntegrityWhitelistRequest) (*similarity.RemoveIntegrityWhitelistResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemoveIntegrityWhitelist", ctx, req) + ret0, _ := ret[0].(*similarity.RemoveIntegrityWhitelistResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemoveIntegrityWhitelist indicates an expected call of RemoveIntegrityWhitelist. +func (mr *MockAdminSvcMockRecorder) RemoveIntegrityWhitelist(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveIntegrityWhitelist", reflect.TypeOf((*MockAdminSvc)(nil).RemoveIntegrityWhitelist), ctx, req) +} + +// RemovePairWhitelist mocks base method. +func (m *MockAdminSvc) RemovePairWhitelist(ctx context.Context, req *similarity.RemovePairWhitelistRequest) (*similarity.RemovePairWhitelistResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemovePairWhitelist", ctx, req) + ret0, _ := ret[0].(*similarity.RemovePairWhitelistResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemovePairWhitelist indicates an expected call of RemovePairWhitelist. +func (mr *MockAdminSvcMockRecorder) RemovePairWhitelist(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePairWhitelist", reflect.TypeOf((*MockAdminSvc)(nil).RemovePairWhitelist), ctx, req) +} + +// RemovePairWhitelistByID mocks base method. +func (m *MockAdminSvc) RemovePairWhitelistByID(ctx context.Context, req *similarity.RemovePairWhitelistByIDRequest) (*similarity.RemovePairWhitelistByIDResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RemovePairWhitelistByID", ctx, req) + ret0, _ := ret[0].(*similarity.RemovePairWhitelistByIDResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RemovePairWhitelistByID indicates an expected call of RemovePairWhitelistByID. +func (mr *MockAdminSvcMockRecorder) RemovePairWhitelistByID(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePairWhitelistByID", reflect.TypeOf((*MockAdminSvc)(nil).RemovePairWhitelistByID), ctx, req) +} + +// ResolveIntegrityReview mocks base method. +func (m *MockAdminSvc) ResolveIntegrityReview(ctx context.Context, req *similarity.ResolveIntegrityReviewRequest) (*similarity.ResolveIntegrityReviewResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveIntegrityReview", ctx, req) + ret0, _ := ret[0].(*similarity.ResolveIntegrityReviewResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveIntegrityReview indicates an expected call of ResolveIntegrityReview. +func (mr *MockAdminSvcMockRecorder) ResolveIntegrityReview(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveIntegrityReview", reflect.TypeOf((*MockAdminSvc)(nil).ResolveIntegrityReview), ctx, req) +} + +// TriggerBackfill mocks base method. +func (m *MockAdminSvc) TriggerBackfill(ctx context.Context, req *similarity.TriggerBackfillRequest) (*similarity.TriggerBackfillResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TriggerBackfill", ctx, req) + ret0, _ := ret[0].(*similarity.TriggerBackfillResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TriggerBackfill indicates an expected call of TriggerBackfill. +func (mr *MockAdminSvcMockRecorder) TriggerBackfill(ctx, req any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TriggerBackfill", reflect.TypeOf((*MockAdminSvc)(nil).TriggerBackfill), ctx, req) +} diff --git a/internal/service/similarity_svc/mock/integrity.go b/internal/service/similarity_svc/mock/integrity.go new file mode 100644 index 0000000..75d34bc --- /dev/null +++ b/internal/service/similarity_svc/mock/integrity.go @@ -0,0 +1,99 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: integrity.go +// +// Generated by this command: +// +// mockgen -source=integrity.go -destination=mock/integrity.go +// + +// Package mock_similarity_svc is a generated GoMock package. +package mock_similarity_svc + +import ( + context "context" + reflect "reflect" + + similarity_svc "github.com/scriptscat/scriptlist/internal/service/similarity_svc" + gomock "go.uber.org/mock/gomock" +) + +// MockIntegritySvc is a mock of IntegritySvc interface. +type MockIntegritySvc struct { + ctrl *gomock.Controller + recorder *MockIntegritySvcMockRecorder + isgomock struct{} +} + +// MockIntegritySvcMockRecorder is the mock recorder for MockIntegritySvc. +type MockIntegritySvcMockRecorder struct { + mock *MockIntegritySvc +} + +// NewMockIntegritySvc creates a new mock instance. +func NewMockIntegritySvc(ctrl *gomock.Controller) *MockIntegritySvc { + mock := &MockIntegritySvc{ctrl: ctrl} + mock.recorder = &MockIntegritySvcMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIntegritySvc) EXPECT() *MockIntegritySvcMockRecorder { + return m.recorder +} + +// Check mocks base method. +func (m *MockIntegritySvc) Check(ctx context.Context, code string) *similarity_svc.IntegrityResult { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Check", ctx, code) + ret0, _ := ret[0].(*similarity_svc.IntegrityResult) + return ret0 +} + +// Check indicates an expected call of Check. +func (mr *MockIntegritySvcMockRecorder) Check(ctx, code any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Check", reflect.TypeOf((*MockIntegritySvc)(nil).Check), ctx, code) +} + +// CheckFast mocks base method. +func (m *MockIntegritySvc) CheckFast(ctx context.Context, code string) *similarity_svc.IntegrityResult { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CheckFast", ctx, code) + ret0, _ := ret[0].(*similarity_svc.IntegrityResult) + return ret0 +} + +// CheckFast indicates an expected call of CheckFast. +func (mr *MockIntegritySvcMockRecorder) CheckFast(ctx, code any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckFast", reflect.TypeOf((*MockIntegritySvc)(nil).CheckFast), ctx, code) +} + +// IsWhitelisted mocks base method. +func (m *MockIntegritySvc) IsWhitelisted(ctx context.Context, scriptID int64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsWhitelisted", ctx, scriptID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsWhitelisted indicates an expected call of IsWhitelisted. +func (mr *MockIntegritySvcMockRecorder) IsWhitelisted(ctx, scriptID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsWhitelisted", reflect.TypeOf((*MockIntegritySvc)(nil).IsWhitelisted), ctx, scriptID) +} + +// RecordWarning mocks base method. +func (m *MockIntegritySvc) RecordWarning(ctx context.Context, scriptID, scriptCodeID, userID int64, result *similarity_svc.IntegrityResult) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RecordWarning", ctx, scriptID, scriptCodeID, userID, result) + ret0, _ := ret[0].(error) + return ret0 +} + +// RecordWarning indicates an expected call of RecordWarning. +func (mr *MockIntegritySvcMockRecorder) RecordWarning(ctx, scriptID, scriptCodeID, userID, result any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecordWarning", reflect.TypeOf((*MockIntegritySvc)(nil).RecordWarning), ctx, scriptID, scriptCodeID, userID, result) +} diff --git a/internal/service/similarity_svc/mock/scan.go b/internal/service/similarity_svc/mock/scan.go new file mode 100644 index 0000000..9a573cd --- /dev/null +++ b/internal/service/similarity_svc/mock/scan.go @@ -0,0 +1,55 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./scan.go +// +// Generated by this command: +// +// mockgen -source=./scan.go -destination=./mock/scan.go +// + +// Package mock_similarity_svc is a generated GoMock package. +package mock_similarity_svc + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockScanSvc is a mock of ScanSvc interface. +type MockScanSvc struct { + ctrl *gomock.Controller + recorder *MockScanSvcMockRecorder + isgomock struct{} +} + +// MockScanSvcMockRecorder is the mock recorder for MockScanSvc. +type MockScanSvcMockRecorder struct { + mock *MockScanSvc +} + +// NewMockScanSvc creates a new mock instance. +func NewMockScanSvc(ctrl *gomock.Controller) *MockScanSvc { + mock := &MockScanSvc{ctrl: ctrl} + mock.recorder = &MockScanSvcMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockScanSvc) EXPECT() *MockScanSvcMockRecorder { + return m.recorder +} + +// Scan mocks base method. +func (m *MockScanSvc) Scan(ctx context.Context, scriptID int64, force bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Scan", ctx, scriptID, force) + ret0, _ := ret[0].(error) + return ret0 +} + +// Scan indicates an expected call of Scan. +func (mr *MockScanSvcMockRecorder) Scan(ctx, scriptID, force any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Scan", reflect.TypeOf((*MockScanSvc)(nil).Scan), ctx, scriptID, force) +} diff --git a/internal/service/similarity_svc/pending_warning.go b/internal/service/similarity_svc/pending_warning.go new file mode 100644 index 0000000..6d646ee --- /dev/null +++ b/internal/service/similarity_svc/pending_warning.go @@ -0,0 +1,15 @@ +package similarity_svc + +type IntegrityResult struct { + Score float64 + SubScores map[string]float64 + HitSignals []SignalHit + KnownPacker bool + Partial bool +} + +type SignalHit struct { + Name string `json:"name"` + Value float64 `json:"value"` + Threshold float64 `json:"threshold"` +} diff --git a/internal/service/similarity_svc/purge.go b/internal/service/similarity_svc/purge.go new file mode 100644 index 0000000..bc97861 --- /dev/null +++ b/internal/service/similarity_svc/purge.go @@ -0,0 +1,55 @@ +package similarity_svc + +import ( + "context" + + "github.com/cago-frame/cago/pkg/logger" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + "go.uber.org/zap" +) + +// PurgeScriptData cascades cleanup of all similarity artifacts for a single +// script (spec §4.6). Must only be called on *physical* (hard) delete: the +// spec explicitly preserves fingerprints of soft-deleted scripts as evidence +// for later copycat detection. +// +// Cleanup order: +// 1. ES fingerprint docs (best-effort; logged on failure) +// 2. pre_script_fingerprint row +// 3. pre_script_similar_pair rows mentioning this script (either side) +// 4. pre_script_suspect_summary row +// +// Errors in any single store are logged and suppressed so later stores still +// get cleaned — we prefer a partially-purged state over aborting midway. +// The first returned error (if any) is propagated to the caller for metrics / +// retry logic. +func PurgeScriptData(ctx context.Context, scriptID int64) error { + log := logger.Ctx(ctx).With(zap.Int64("script_id", scriptID)) + var firstErr error + + if err := similarity_repo.FingerprintES().DeleteByScriptID(ctx, scriptID); err != nil { + log.Warn("similarity purge: es delete failed", zap.Error(err)) + firstErr = err + } + if err := similarity_repo.Fingerprint().Delete(ctx, scriptID); err != nil { + log.Warn("similarity purge: fingerprint row delete failed", zap.Error(err)) + if firstErr == nil { + firstErr = err + } + } + if err := similarity_repo.SimilarPair().DeleteByScriptID(ctx, scriptID); err != nil { + log.Warn("similarity purge: similar_pair delete failed", zap.Error(err)) + if firstErr == nil { + firstErr = err + } + } + if err := similarity_repo.SuspectSummary().Delete(ctx, scriptID); err != nil { + log.Warn("similarity purge: suspect_summary delete failed", zap.Error(err)) + if firstErr == nil { + firstErr = err + } + } + + log.Info("similarity purge: done") + return firstErr +} diff --git a/internal/service/similarity_svc/purge_test.go b/internal/service/similarity_svc/purge_test.go new file mode 100644 index 0000000..ccb37da --- /dev/null +++ b/internal/service/similarity_svc/purge_test.go @@ -0,0 +1,64 @@ +package similarity_svc + +import ( + "context" + "errors" + "testing" + + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + mock_similarity_repo "github.com/scriptscat/scriptlist/internal/repository/similarity_repo/mock" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +// TestPurgeScriptData_AllStoresCleaned verifies that a successful purge +// cascades into all four similarity stores exactly once. +func TestPurgeScriptData_AllStoresCleaned(t *testing.T) { + scanGlobalMu.Lock() + defer scanGlobalMu.Unlock() + + ctrl := gomock.NewController(t) + fpRepo := mock_similarity_repo.NewMockFingerprintRepo(ctrl) + esRepo := mock_similarity_repo.NewMockFingerprintESRepo(ctrl) + pairRepo := mock_similarity_repo.NewMockSimilarPairRepo(ctrl) + summaryRepo := mock_similarity_repo.NewMockSuspectSummaryRepo(ctrl) + similarity_repo.RegisterFingerprint(fpRepo) + similarity_repo.RegisterFingerprintES(esRepo) + similarity_repo.RegisterSimilarPair(pairRepo) + similarity_repo.RegisterSuspectSummary(summaryRepo) + + esRepo.EXPECT().DeleteByScriptID(gomock.Any(), int64(42)).Return(nil) + fpRepo.EXPECT().Delete(gomock.Any(), int64(42)).Return(nil) + pairRepo.EXPECT().DeleteByScriptID(gomock.Any(), int64(42)).Return(nil) + summaryRepo.EXPECT().Delete(gomock.Any(), int64(42)).Return(nil) + + err := PurgeScriptData(context.Background(), 42) + assert.NoError(t, err) +} + +// TestPurgeScriptData_PartialFailure_ContinuesAndReportsFirst verifies that a +// failure in one store does not short-circuit the remaining cleanups and the +// first error is returned. +func TestPurgeScriptData_PartialFailure_ContinuesAndReportsFirst(t *testing.T) { + scanGlobalMu.Lock() + defer scanGlobalMu.Unlock() + + ctrl := gomock.NewController(t) + fpRepo := mock_similarity_repo.NewMockFingerprintRepo(ctrl) + esRepo := mock_similarity_repo.NewMockFingerprintESRepo(ctrl) + pairRepo := mock_similarity_repo.NewMockSimilarPairRepo(ctrl) + summaryRepo := mock_similarity_repo.NewMockSuspectSummaryRepo(ctrl) + similarity_repo.RegisterFingerprint(fpRepo) + similarity_repo.RegisterFingerprintES(esRepo) + similarity_repo.RegisterSimilarPair(pairRepo) + similarity_repo.RegisterSuspectSummary(summaryRepo) + + esErr := errors.New("es down") + esRepo.EXPECT().DeleteByScriptID(gomock.Any(), int64(7)).Return(esErr) + fpRepo.EXPECT().Delete(gomock.Any(), int64(7)).Return(nil) + pairRepo.EXPECT().DeleteByScriptID(gomock.Any(), int64(7)).Return(nil) + summaryRepo.EXPECT().Delete(gomock.Any(), int64(7)).Return(nil) + + err := PurgeScriptData(context.Background(), 7) + assert.ErrorIs(t, err, esErr) +} diff --git a/internal/service/similarity_svc/scan.go b/internal/service/similarity_svc/scan.go new file mode 100644 index 0000000..8a6ba56 --- /dev/null +++ b/internal/service/similarity_svc/scan.go @@ -0,0 +1,540 @@ +package similarity_svc + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "strconv" + "time" + + "github.com/cago-frame/cago/database/redis" + "github.com/cago-frame/cago/pkg/consts" + "github.com/cago-frame/cago/pkg/logger" + "github.com/scriptscat/scriptlist/configs" + "github.com/scriptscat/scriptlist/internal/model/entity/script_entity" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + "github.com/scriptscat/scriptlist/internal/repository/script_repo" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + "go.uber.org/zap" +) + +//go:generate mockgen -source=./scan.go -destination=./mock/scan.go + +// Function-typed package vars allow unit tests to substitute fakes for +// Redis/config dependencies without standing up a real Redis or +// initializing cago configs. Production callers use the defaults below. +var ( + loadSimilarityConfig = func() *configs.SimilarityConfig { + return configs.Similarity() + } + acquireScanLock = func(ctx context.Context, scriptID int64) (acquired bool, release func(), err error) { + ok, err := redis.Ctx(ctx).SetNX(scanLockKey(scriptID), "1", scanLockTTL).Result() + if err != nil { + return false, func() {}, err + } + if !ok { + return false, func() {}, nil + } + release = func() { + _, _ = redis.Ctx(ctx).Del(scanLockKey(scriptID)).Result() + } + return true, release, nil + } + loadStopFpsFn = func(ctx context.Context) ([]string, error) { + return redis.Ctx(ctx).Client.SMembers(ctx, StopFpRedisKey).Result() + } +) + +// ScanSvc orchestrates a single similarity scan for one script. +// +// Scan is triggered by the similarity.scan NSQ consumer (see task 18) and +// performs: lock → load → k-gram/winnow extract → ES index → candidate +// search → pairwise Jaccard → suspect summary upsert. +// +// force=true bypasses the code_hash short-circuit so an existing OK row with +// identical source still gets re-extracted, re-indexed, and re-paired. Used +// by the admin "从头回填 (reset)" flow to truly force a full rescan. +type ScanSvc interface { + Scan(ctx context.Context, scriptID int64, force bool) error +} + +var defaultScan ScanSvc + +// ScanService returns the registered default ScanSvc. +func ScanService() ScanSvc { return defaultScan } + +// RegisterScan registers svc as the process-wide default ScanSvc. +func RegisterScan(svc ScanSvc) { defaultScan = svc } + +type scanSvc struct{} + +// NewScanSvc constructs a new ScanSvc using the default repo service locators. +func NewScanSvc() ScanSvc { return &scanSvc{} } + +// StopFpRedisKey holds the current stop-fingerprint set (populated by the +// Task 20 crontab). It is a Redis SET of hex-encoded uint64 fingerprints. +// Shared between the scan service (reader) and the stop-fp crontab (writer). +const StopFpRedisKey = "similarity:stop_fp" + +// scanLockTTL is the upper bound on how long one scan is expected to run. +// We auto-release via defer, but the TTL also guards against crashed workers. +const scanLockTTL = 60 * time.Second + +// maxCandidates is the hard cap on ES candidate fan-out per scan. +const maxCandidates = 100 + +func scanLockKey(scriptID int64) string { + return "similarity:scan:" + strconv.FormatInt(scriptID, 10) +} + +func (s *scanSvc) Scan(ctx context.Context, scriptID int64, force bool) error { + if scriptID <= 0 { + return errors.New("similarity_svc: invalid script_id") + } + cfg := loadSimilarityConfig() + if !cfg.ScanEnabled { + return nil + } + log := logger.Ctx(ctx).With(zap.Int64("script_id", scriptID), zap.Bool("force", force)) + startedAt := time.Now() + log.Info("similarity scan: start") + + // 1. Acquire Redis lock; silently skip if another worker holds it. + acquired, release, err := acquireScanLock(ctx, scriptID) + if err != nil { + log.Error("similarity scan: redis lock failed", zap.Error(err)) + return err + } + if !acquired { + log.Info("similarity scan: lock held by peer, skipping") + return nil + } + defer release() + + // 2. Load script and latest code row. + script, err := script_repo.Script().Find(ctx, scriptID) + if err != nil { + return err + } + if script == nil { + log.Info("similarity scan: script not found") + return nil + } + codeRow, err := script_repo.ScriptCode().FindLatest(ctx, scriptID, 0, true) + if err != nil { + return err + } + if codeRow == nil { + log.Info("similarity scan: no code row") + return nil + } + + now := time.Now().Unix() + + // Guard clauses: soft-deleted script or oversized code → mark skip. + // On every early-exit path we also purge stale pending pairs touching this + // script. Otherwise scripts that *used to* match but now hit a guard (got + // soft-deleted, grew past the size limit, became unparseable, shrank below + // the min-fingerprint floor) leave their old pairs frozen forever in the + // admin list, since no later scan reaches step 10b for them. + if script.Status != consts.ACTIVE { + log.Info("similarity scan: script not active, marking skip") + purgePendingPairs(ctx, log, scriptID) + return similarity_repo.Fingerprint().UpdateParseStatus(ctx, scriptID, + similarity_entity.ParseStatusSkip, "soft_deleted", now) + } + if cfg.MaxCodeSize > 0 && len(codeRow.Code) > cfg.MaxCodeSize { + log.Info("similarity scan: code too large, marking skip", + zap.Int("size", len(codeRow.Code))) + purgePendingPairs(ctx, log, scriptID) + return similarity_repo.Fingerprint().UpdateParseStatus(ctx, scriptID, + similarity_entity.ParseStatusSkip, "too_large", now) + } + + // 2b. 异步完整性检查(全部信号),处理警告和自动归档 + if cfg.IntegrityEnabled && Integrity() != nil { + whitelisted, _ := similarity_repo.IntegrityWhitelist().IsWhitelisted(ctx, scriptID) + if whitelisted { + log.Debug("integrity async: whitelisted, skipping") + } else { + intResult := Integrity().Check(ctx, codeRow.Code) + if intResult.Score >= cfg.IntegrityBlockThreshold && cfg.IntegrityAsyncAutoArchive != nil && *cfg.IntegrityAsyncAutoArchive { + script.Archive = script_entity.IsArchive + script.Updatetime = time.Now().Unix() + if err := script_repo.Script().Update(ctx, script); err != nil { + log.Error("integrity async block: auto-archive failed", zap.Error(err)) + } else { + log.Warn("integrity async block: script auto-archived", + zap.Float64("score", intResult.Score)) + } + } + if intResult.Score >= cfg.IntegrityWarnThreshold { + if err := Integrity().RecordWarning(ctx, scriptID, codeRow.ID, script.UserID, intResult); err != nil { + log.Error("integrity async: record warning failed", zap.Error(err)) + } + } + } + } + + // 3. code_hash short-circuit: if the existing row already covered this + // exact source and parsed OK, skip the rescan. force=true bypasses + // this branch so admin-triggered "从头回填" can truly re-extract. + codeHash := sha256Hex(codeRow.Code) + existing, err := similarity_repo.Fingerprint().FindByScriptID(ctx, scriptID) + if err != nil { + return err + } + if !force && existing != nil && existing.CodeHash == codeHash && + existing.ParseStatus == similarity_entity.ParseStatusOK { + log.Info("similarity scan: code unchanged, skipping") + return nil + } + + // 4. Assign a new batch_id for this scan (microsecond resolution). + batchID := time.Now().UnixMicro() + + // 5. Extract fingerprints from the source. Parse failures are recorded + // as parse_status=failed so operators can surface broken scripts. + log.Debug("similarity scan: extracting fingerprints", + zap.Int("code_size", len(codeRow.Code)), + zap.Int("k_gram", cfg.KGramSize), + zap.Int("window", cfg.WinnowingWindow), + zap.Int64("batch_id", batchID), + ) + extractStart := time.Now() + result, err := ExtractFingerprints(codeRow.Code, FingerprintOptions{ + KGramSize: cfg.KGramSize, + WinnowingWindow: cfg.WinnowingWindow, + }) + if err != nil || result == nil || result.ParseError != nil { + log.Warn("similarity scan: parse failed", zap.Error(err)) + purgePendingPairs(ctx, log, scriptID) + return similarity_repo.Fingerprint().UpdateParseStatus(ctx, scriptID, + similarity_entity.ParseStatusFailed, parseErrString(err, result), now) + } + if len(result.Fingerprints) < cfg.MinFingerprints { + log.Info("similarity scan: too few fingerprints, marking skip", + zap.Int("count", len(result.Fingerprints))) + purgePendingPairs(ctx, log, scriptID) + return similarity_repo.Fingerprint().UpdateParseStatus(ctx, scriptID, + similarity_entity.ParseStatusSkip, "too_few_fingerprints", now) + } + log.Debug("similarity scan: fingerprints extracted", + zap.Int("fp_count", len(result.Fingerprints)), + zap.Int("total_tokens", result.TotalTokens), + zap.Duration("extract_elapsed", time.Since(extractStart)), + ) + + // 6. Bulk-insert all fingerprints into Elasticsearch under the new batch. + docs := make([]similarity_repo.FingerprintDoc, 0, len(result.Fingerprints)) + for _, fp := range result.Fingerprints { + docs = append(docs, similarity_repo.FingerprintDoc{ + ScriptID: scriptID, + UserID: script.UserID, + BatchID: batchID, + Fingerprint: hexU64(fp.Hash), + Position: fp.Position, + }) + } + if err := similarity_repo.FingerprintES().BulkInsert(ctx, docs); err != nil { + log.Error("similarity scan: es bulk insert failed", zap.Error(err)) + return err + } + + // 7. Load the current stop-fp set and compute effective fingerprint count. + stopFps, err := loadStopFpsFn(ctx) + if err != nil { + log.Warn("similarity scan: stop-fp load failed, treating as empty", + zap.Error(err)) + stopFps = nil + } + stopSet := toSet(stopFps) + effective := 0 + for _, d := range docs { + if _, isStop := stopSet[d.Fingerprint]; !isStop { + effective++ + } + } + + // 8. UPSERT the fingerprint metadata row. + fpRow := &similarity_entity.Fingerprint{ + ScriptID: scriptID, + UserID: script.UserID, + ScriptCodeID: codeRow.ID, + FingerprintCnt: len(docs), + FingerprintCntEffective: effective, + CodeHash: codeHash, + BatchID: batchID, + ParseStatus: similarity_entity.ParseStatusOK, + ParseError: "", + ScannedAt: now, + Createtime: now, + Updatetime: now, + } + if err := similarity_repo.Fingerprint().Upsert(ctx, fpRow); err != nil { + log.Error("similarity scan: fingerprint upsert failed", zap.Error(err)) + return err + } + + // 9. Clean up old batches in ES (best-effort; don't fail the scan). + if err := similarity_repo.FingerprintES().DeleteOldBatches(ctx, scriptID, batchID); err != nil { + log.Warn("similarity scan: cleanup old batches failed", zap.Error(err)) + } + + // 10. Find candidate scripts via ES aggregation, excluding stop-fps. + queryFps := filterStopFps(docs, stopSet) + candidates, err := similarity_repo.FingerprintES().FindCandidates( + ctx, scriptID, script.UserID, queryFps, stopFps, maxCandidates) + if err != nil { + log.Error("similarity scan: find candidates failed", zap.Error(err)) + return err + } + + // 10b. Clear stale pending pairs for this script before re-upserting. Any + // pair whose current partner is *still* similar will be re-created by step + // 11's Upsert; pairs that no longer match (because the code changed and + // dropped below threshold) would otherwise linger as zombie rows and keep + // showing in the admin list. Whitelisted / reviewed pairs are preserved — + // those statuses are explicit admin decisions. + purgePendingPairs(ctx, log, scriptID) + + // 11. Score each candidate and persist qualifying pairs. + pairCount := 0 + maxJaccard := 0.0 + topSources := make([]similarity_entity.TopSource, 0, len(candidates)) + for _, c := range candidates { + other, err := similarity_repo.Fingerprint().FindByScriptID(ctx, c.ScriptID) + if err != nil { + log.Warn("similarity scan: load candidate fingerprint failed", + zap.Error(err), zap.Int64("candidate", c.ScriptID)) + continue + } + if other == nil { + continue + } + // Jaccard = |A ∩ B| / |A ∪ B| where |A ∪ B| = |A| + |B| - |A ∩ B|. + // + // Approximation note: `effective` is computed with the current stop-fp + // set, but `other.FingerprintCntEffective` was stored during script B's + // last scan and may reflect a different stop-fp set. When the stop-fp + // set grows the denominator can go negative (handled by the denom <= 0 + // guard below); when it shrinks the raw Jaccard can exceed 1.0 (handled + // by the clamp below). The approximation converges automatically as + // scripts are rescanned with the latest stop-fp set. + denom := effective + other.FingerprintCntEffective - c.CommonCount + if denom <= 0 { + continue + } + jaccard := float64(c.CommonCount) / float64(denom) + if jaccard > 1.0 { + jaccard = 1.0 + } + if jaccard > maxJaccard { + maxJaccard = jaccard + } + if jaccard < cfg.JaccardThreshold { + continue + } + + pairCount++ + whitelisted, _ := similarity_repo.SimilarityWhitelist(). + IsWhitelisted(ctx, scriptID, c.ScriptID) + pairStatus := similarity_entity.PairStatusPending + if whitelisted { + pairStatus = similarity_entity.PairStatusWhitelisted + } + + var otherCodeCreated int64 + if otherCode, cerr := script_repo.ScriptCode().Find(ctx, other.ScriptCodeID); cerr == nil && otherCode != nil { + otherCodeCreated = otherCode.Createtime + } + + pair := buildPair(scriptID, c.ScriptID, + script.UserID, c.UserID, + codeRow.ID, other.ScriptCodeID, + codeRow.Createtime, otherCodeCreated, + effective, other.FingerprintCntEffective, + c.CommonCount, jaccard, + pairStatus, now, + ) + if err := similarity_repo.SimilarPair().Upsert(ctx, pair); err != nil { + log.Warn("similarity scan: pair upsert failed", zap.Error(err), + zap.Int64("candidate", c.ScriptID)) + continue + } + + contribution := 0.0 + if effective > 0 { + contribution = float64(c.CommonCount) / float64(effective) + } + topSources = append(topSources, similarity_entity.TopSource{ + ScriptID: c.ScriptID, + Jaccard: jaccard, + ContributionPct: contribution, + }) + } + + // 12. Coverage (spec §4.1 Step 5): distinct count of FP(X) fingerprints + // that appear in any *other* script (different script_id AND user_id), + // divided by X's effective fingerprint count. A single ES cardinality + // aggregation — summing per-candidate CommonCount would double-count + // fingerprints shared across multiple candidates. + coverage := 0.0 + if effective > 0 && len(queryFps) > 0 { + distinctMatched, cerr := similarity_repo.FingerprintES(). + CountDistinctMatched(ctx, scriptID, script.UserID, queryFps, stopFps) + if cerr != nil { + log.Warn("similarity scan: coverage aggregation failed, falling back to 0", + zap.Error(cerr)) + } else { + coverage = float64(distinctMatched) / float64(effective) + if coverage > 1 { + coverage = 1 + } + } + } + + // 13. UPSERT suspect_summary when we found at least one qualifying pair + // OR when coverage crosses the threshold (even if no single pair did). + if pairCount > 0 || coverage >= cfg.CoverageThreshold { + topJSON, jerr := json.Marshal(topSources) + if jerr != nil { + topJSON = []byte("[]") + } + summary := &similarity_entity.SuspectSummary{ + ScriptID: scriptID, + UserID: script.UserID, + MaxJaccard: maxJaccard, + Coverage: coverage, + TopSources: string(topJSON), + PairCount: pairCount, + DetectedAt: now, + Status: similarity_entity.SuspectStatusPending, + Createtime: now, + Updatetime: now, + } + if err := similarity_repo.SuspectSummary().Upsert(ctx, summary); err != nil { + log.Warn("similarity scan: suspect summary upsert failed", zap.Error(err)) + } + } + + log.Info("similarity scan: done", + zap.Int("fp_cnt", len(docs)), + zap.Int("fp_effective", effective), + zap.Int("candidates", len(candidates)), + zap.Int("pair_count", pairCount), + zap.Float64("max_jaccard", maxJaccard), + zap.Float64("coverage", coverage), + zap.Duration("elapsed", time.Since(startedAt)), + ) + return nil +} + +// buildPair assembles a SimilarPair for (curID, otherID), swapping A/B fields +// so the caller's ID is always "current" regardless of normalization. The +// repo's Upsert normalizes (A < B) again internally, so this function also +// swaps the paired fields to keep them aligned post-normalization. +func buildPair( + curID, otherID int64, + curUser, otherUser int64, + curCodeID, otherCodeID int64, + curCodeCreated, otherCodeCreated int64, + curFpCnt, otherFpCnt int, + commonCount int, + jaccard float64, + status similarity_entity.PairStatus, + now int64, +) *similarity_entity.SimilarPair { + aID, bID := curID, otherID + aUser, bUser := curUser, otherUser + aCodeID, bCodeID := curCodeID, otherCodeID + aCodeAt, bCodeAt := curCodeCreated, otherCodeCreated + aFp, bFp := curFpCnt, otherFpCnt + if curID > otherID { + aID, bID = otherID, curID + aUser, bUser = otherUser, curUser + aCodeID, bCodeID = otherCodeID, curCodeID + aCodeAt, bCodeAt = otherCodeCreated, curCodeCreated + aFp, bFp = otherFpCnt, curFpCnt + } + return &similarity_entity.SimilarPair{ + ScriptAID: aID, + ScriptBID: bID, + UserAID: aUser, + UserBID: bUser, + AScriptCodeID: aCodeID, + BScriptCodeID: bCodeID, + ACodeCreatedAt: aCodeAt, + BCodeCreatedAt: bCodeAt, + Jaccard: jaccard, + CommonCount: commonCount, + AFpCount: aFp, + BFpCount: bFp, + MatchedFp: []byte{}, + DetectedAt: now, + Status: status, + Createtime: now, + Updatetime: now, + } +} + +// purgePendingPairs deletes pending similar-pair rows touching this script. +// Errors are logged at Warn but never bubble up: pair cleanup is best-effort +// and must not fail an otherwise-successful scan or skip-status write. +func purgePendingPairs(ctx context.Context, log *zap.Logger, scriptID int64) { + if err := similarity_repo.SimilarPair().DeletePendingByScriptID(ctx, scriptID); err != nil { + log.Warn("similarity scan: pending pair cleanup failed", zap.Error(err)) + } +} + +// sha256Hex returns the hex-encoded SHA-256 of s. +func sha256Hex(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// hexU64 formats v as a 16-char lowercase hex string (zero-padded). +func hexU64(v uint64) string { + const hexdigits = "0123456789abcdef" + out := make([]byte, 16) + for i := 0; i < 16; i++ { + out[15-i] = hexdigits[v&0xF] + v >>= 4 + } + return string(out) +} + +// parseErrString extracts the best available error message from either the +// top-level err or the FingerprintResult.ParseError field. +func parseErrString(err error, r *FingerprintResult) string { + if err != nil { + return err.Error() + } + if r != nil && r.ParseError != nil { + return r.ParseError.Error() + } + return "unknown" +} + +// toSet materializes a string slice as a lookup set. +func toSet(values []string) map[string]struct{} { + out := make(map[string]struct{}, len(values)) + for _, v := range values { + out[v] = struct{}{} + } + return out +} + +// filterStopFps returns the fingerprints from docs that are NOT in stopSet. +func filterStopFps(docs []similarity_repo.FingerprintDoc, stopSet map[string]struct{}) []string { + out := make([]string, 0, len(docs)) + for _, d := range docs { + if _, isStop := stopSet[d.Fingerprint]; isStop { + continue + } + out = append(out, d.Fingerprint) + } + return out +} diff --git a/internal/service/similarity_svc/scan_test.go b/internal/service/similarity_svc/scan_test.go new file mode 100644 index 0000000..f4f4637 --- /dev/null +++ b/internal/service/similarity_svc/scan_test.go @@ -0,0 +1,585 @@ +package similarity_svc + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + + "github.com/cago-frame/cago/pkg/consts" + "github.com/scriptscat/scriptlist/configs" + "github.com/scriptscat/scriptlist/internal/model/entity/script_entity" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + "github.com/scriptscat/scriptlist/internal/repository/script_repo" + mock_script_repo "github.com/scriptscat/scriptlist/internal/repository/script_repo/mock" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + mock_similarity_repo "github.com/scriptscat/scriptlist/internal/repository/similarity_repo/mock" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +// TestScanSvc_InterfaceShape confirms that *scanSvc satisfies the ScanSvc +// interface and that the constructor returns a non-nil value. Behavioral +// tests with mocked repos live below (task 17). +func TestScanSvc_InterfaceShape(t *testing.T) { + var _ ScanSvc = (*scanSvc)(nil) + assert.NotNil(t, NewScanSvc()) +} + +// TestScan_InvalidScriptID guards the early-return for invalid inputs: we +// don't want Scan to touch Redis / repos when the caller passes a zero or +// negative script id. +func TestScan_InvalidScriptID(t *testing.T) { + svc := NewScanSvc() + err := svc.Scan(context.Background(), 0, false) + assert.Error(t, err) + + err = svc.Scan(context.Background(), -1, false) + assert.Error(t, err) +} + +// --- Behavioral tests with mocked repos (task 17) --- + +// scanGlobalMu serializes access to the package-level service-locator so +// parallel tests don't race on RegisterXxx + the scan.go function vars. +var scanGlobalMu sync.Mutex + +type scanMocks struct { + scriptRepo *mock_script_repo.MockScriptRepo + scriptCodeRepo *mock_script_repo.MockScriptCodeRepo + fingerprint *mock_similarity_repo.MockFingerprintRepo + es *mock_similarity_repo.MockFingerprintESRepo + pair *mock_similarity_repo.MockSimilarPairRepo + summary *mock_similarity_repo.MockSuspectSummaryRepo + whitelist *mock_similarity_repo.MockSimilarityWhitelistRepo + + integrityWhitelist *mock_similarity_repo.MockIntegrityWhitelistRepo + + // lockHeld toggles whether the fake Redis lock is "held by another peer". + lockHeld bool + // stopFps is the fake stop-fingerprint set returned by loadStopFpsFn. + stopFps []string + // cfg is the fake config returned by loadSimilarityConfig. + cfg *configs.SimilarityConfig +} + +func setupScanMocks(t *testing.T) (*scanSvc, *scanMocks, context.Context) { + scanGlobalMu.Lock() + t.Cleanup(scanGlobalMu.Unlock) + + ctrl := gomock.NewController(t) + m := &scanMocks{ + scriptRepo: mock_script_repo.NewMockScriptRepo(ctrl), + scriptCodeRepo: mock_script_repo.NewMockScriptCodeRepo(ctrl), + fingerprint: mock_similarity_repo.NewMockFingerprintRepo(ctrl), + es: mock_similarity_repo.NewMockFingerprintESRepo(ctrl), + pair: mock_similarity_repo.NewMockSimilarPairRepo(ctrl), + summary: mock_similarity_repo.NewMockSuspectSummaryRepo(ctrl), + whitelist: mock_similarity_repo.NewMockSimilarityWhitelistRepo(ctrl), + integrityWhitelist: mock_similarity_repo.NewMockIntegrityWhitelistRepo(ctrl), + cfg: &configs.SimilarityConfig{ + ScanEnabled: true, + JaccardThreshold: 0.30, + CoverageThreshold: 0.50, + KGramSize: 5, + WinnowingWindow: 10, + MinFingerprints: 20, + MaxCodeSize: 524288, + }, + } + + script_repo.RegisterScript(m.scriptRepo) + script_repo.RegisterScriptCode(m.scriptCodeRepo) + similarity_repo.RegisterFingerprint(m.fingerprint) + similarity_repo.RegisterFingerprintES(m.es) + similarity_repo.RegisterSimilarPair(m.pair) + similarity_repo.RegisterSuspectSummary(m.summary) + similarity_repo.RegisterSimilarityWhitelist(m.whitelist) + similarity_repo.RegisterIntegrityWhitelist(m.integrityWhitelist) + + // Save originals so we can restore in Cleanup. + origLoadCfg := loadSimilarityConfig + origAcquireLock := acquireScanLock + origLoadStop := loadStopFpsFn + + loadSimilarityConfig = func() *configs.SimilarityConfig { return m.cfg } + acquireScanLock = func(_ context.Context, _ int64) (bool, func(), error) { + if m.lockHeld { + return false, func() {}, nil + } + return true, func() {}, nil + } + loadStopFpsFn = func(_ context.Context) ([]string, error) { + return m.stopFps, nil + } + + t.Cleanup(func() { + loadSimilarityConfig = origLoadCfg + acquireScanLock = origAcquireLock + loadStopFpsFn = origLoadStop + }) + + return &scanSvc{}, m, context.Background() +} + +// TestScan_ScanDisabled_ReturnsNil — when ScanEnabled is false, Scan must +// return immediately without touching any repo. +func TestScan_ScanDisabled_ReturnsNil(t *testing.T) { + svc, m, ctx := setupScanMocks(t) + m.cfg.ScanEnabled = false + + // No EXPECT calls — any repo interaction would fail gomock strict mode. + err := svc.Scan(ctx, 1, false) + assert.NoError(t, err) +} + +// TestScan_LockHeld_ReturnsNil — when the lock is held by a peer worker, Scan +// must no-op before doing any work. +func TestScan_LockHeld_ReturnsNil(t *testing.T) { + svc, m, ctx := setupScanMocks(t) + m.lockHeld = true + + // No EXPECT calls — function should return before touching any repo. + err := svc.Scan(ctx, 1, false) + assert.NoError(t, err) +} + +// TestScan_SoftDeleted_PurgesAndMarksSkip — a soft-deleted script (status != +// ACTIVE) skips the rest of the scan but still purges any old pending pairs +// touching it, since the script is no longer listed publicly. +func TestScan_SoftDeleted_PurgesAndMarksSkip(t *testing.T) { + svc, m, ctx := setupScanMocks(t) + + m.scriptRepo.EXPECT().Find(gomock.Any(), int64(7)).Return(&script_entity.Script{ + ID: 7, UserID: 1, Status: consts.DELETE, + }, nil) + m.scriptCodeRepo.EXPECT().FindLatest(gomock.Any(), int64(7), 0, true).Return(&script_entity.Code{ + ID: 11, ScriptID: 7, Code: "var x = 1;", Createtime: 100, + }, nil) + m.pair.EXPECT().DeletePendingByScriptID(gomock.Any(), int64(7)).Return(nil) + m.fingerprint.EXPECT().UpdateParseStatus( + gomock.Any(), int64(7), similarity_entity.ParseStatusSkip, "soft_deleted", gomock.Any(), + ).Return(nil) + + err := svc.Scan(ctx, 7, false) + assert.NoError(t, err) +} + +// TestScan_TooFewFingerprints_PurgesAndMarksSkip — a tiny script that doesn't +// produce enough fingerprints exits early but still drops its old pending +// pairs (the new content is too small to match anything anyway). +func TestScan_TooFewFingerprints_PurgesAndMarksSkip(t *testing.T) { + svc, m, ctx := setupScanMocks(t) + // Tiny code → at most a handful of tokens → < 20 fingerprints. + m.scriptRepo.EXPECT().Find(gomock.Any(), int64(7)).Return(&script_entity.Script{ + ID: 7, UserID: 1, Status: consts.ACTIVE, + }, nil) + m.scriptCodeRepo.EXPECT().FindLatest(gomock.Any(), int64(7), 0, true).Return(&script_entity.Code{ + ID: 11, ScriptID: 7, Code: "var x = 1;", Createtime: 100, + }, nil) + m.fingerprint.EXPECT().FindByScriptID(gomock.Any(), int64(7)).Return(nil, nil) + m.pair.EXPECT().DeletePendingByScriptID(gomock.Any(), int64(7)).Return(nil) + m.fingerprint.EXPECT().UpdateParseStatus( + gomock.Any(), int64(7), similarity_entity.ParseStatusSkip, "too_few_fingerprints", gomock.Any(), + ).Return(nil) + + err := svc.Scan(ctx, 7, false) + assert.NoError(t, err) +} + +// TestScan_PurgePendingPairsErrorIsLoggedNotFatal — pair cleanup is best +// effort. A failing DeletePendingByScriptID must not abort the scan or leak +// the error to the caller; we still write the parse-status row so callers can +// observe the outcome. +func TestScan_PurgePendingPairsErrorIsLoggedNotFatal(t *testing.T) { + svc, m, ctx := setupScanMocks(t) + + m.scriptRepo.EXPECT().Find(gomock.Any(), int64(7)).Return(&script_entity.Script{ + ID: 7, UserID: 1, Status: consts.DELETE, + }, nil) + m.scriptCodeRepo.EXPECT().FindLatest(gomock.Any(), int64(7), 0, true).Return(&script_entity.Code{ + ID: 11, ScriptID: 7, Code: "var x = 1;", Createtime: 100, + }, nil) + m.pair.EXPECT().DeletePendingByScriptID(gomock.Any(), int64(7)). + Return(errors.New("db connection refused")) + // Still writes the skip row even though purge failed. + m.fingerprint.EXPECT().UpdateParseStatus( + gomock.Any(), int64(7), similarity_entity.ParseStatusSkip, "soft_deleted", gomock.Any(), + ).Return(nil) + + err := svc.Scan(ctx, 7, false) + assert.NoError(t, err) +} + +// TestScan_TooLarge_MarksSkip — source exceeding MaxCodeSize is marked +// parse_status=skip with reason "too_large". +func TestScan_TooLarge_MarksSkip(t *testing.T) { + svc, m, ctx := setupScanMocks(t) + m.cfg.MaxCodeSize = 100 // tiny limit + + m.scriptRepo.EXPECT().Find(gomock.Any(), int64(7)).Return(&script_entity.Script{ + ID: 7, UserID: 1, Status: consts.ACTIVE, + }, nil) + m.scriptCodeRepo.EXPECT().FindLatest(gomock.Any(), int64(7), 0, true).Return(&script_entity.Code{ + ID: 11, ScriptID: 7, Code: strings.Repeat("x", 200), Createtime: 100, + }, nil) + // Early-exit purge: the script just grew past the size limit, any old + // pending pairs touching it are stale. + m.pair.EXPECT().DeletePendingByScriptID(gomock.Any(), int64(7)).Return(nil) + m.fingerprint.EXPECT().UpdateParseStatus( + gomock.Any(), int64(7), similarity_entity.ParseStatusSkip, "too_large", gomock.Any(), + ).Return(nil) + + err := svc.Scan(ctx, 7, false) + assert.NoError(t, err) +} + +// TestScan_CodeUnchanged_SkipReindex — existing fingerprint row's CodeHash +// matches the current code → short-circuit without any ES work. +func TestScan_CodeUnchanged_SkipReindex(t *testing.T) { + svc, m, ctx := setupScanMocks(t) + + code := "var a = 1; var b = a + 2;" + m.scriptRepo.EXPECT().Find(gomock.Any(), int64(7)).Return(&script_entity.Script{ + ID: 7, UserID: 1, Status: consts.ACTIVE, + }, nil) + m.scriptCodeRepo.EXPECT().FindLatest(gomock.Any(), int64(7), 0, true).Return(&script_entity.Code{ + ID: 11, ScriptID: 7, Code: code, Createtime: 100, + }, nil) + m.fingerprint.EXPECT().FindByScriptID(gomock.Any(), int64(7)).Return(&similarity_entity.Fingerprint{ + ScriptID: 7, + CodeHash: sha256Hex(code), + ParseStatus: similarity_entity.ParseStatusOK, + }, nil) + + // No further EXPECTs — Scan should return after the hash short-circuit. + err := svc.Scan(ctx, 7, false) + assert.NoError(t, err) +} + +// TestScan_CodeUnchanged_ForceBypassesShortCircuit — same precondition as +// the test above (existing OK row with matching code_hash) but force=true, +// so the short-circuit must not fire and the full extract/index/pair flow +// must run. Guards the §8.5 "从头回填" force path. +func TestScan_CodeUnchanged_ForceBypassesShortCircuit(t *testing.T) { + svc, m, ctx := setupScanMocks(t) + + code := buildScanTestJS() + m.scriptRepo.EXPECT().Find(gomock.Any(), int64(7)).Return(&script_entity.Script{ + ID: 7, UserID: 1, Status: consts.ACTIVE, + }, nil) + m.scriptCodeRepo.EXPECT().FindLatest(gomock.Any(), int64(7), 0, true).Return(&script_entity.Code{ + ID: 11, ScriptID: 7, Code: code, Createtime: 100, + }, nil) + // Existing row with the same hash + OK — would trigger short-circuit + // under force=false, but force=true must bypass it. + m.fingerprint.EXPECT().FindByScriptID(gomock.Any(), int64(7)).Return(&similarity_entity.Fingerprint{ + ScriptID: 7, + CodeHash: sha256Hex(code), + ParseStatus: similarity_entity.ParseStatusOK, + }, nil) + // Full flow expected: ES bulk-insert, fingerprint upsert, cleanup, and + // candidate search. An empty candidate list keeps the assertion surface + // minimal — we only care that we got past the short-circuit. + m.es.EXPECT().BulkInsert(gomock.Any(), gomock.Any()).Return(nil) + m.fingerprint.EXPECT().Upsert(gomock.Any(), gomock.Any()).Return(nil) + m.es.EXPECT().DeleteOldBatches(gomock.Any(), int64(7), gomock.Any()).Return(nil) + m.es.EXPECT().FindCandidates( + gomock.Any(), int64(7), int64(1), gomock.Any(), gomock.Any(), maxCandidates, + ).Return(nil, nil) + // 10b: 批前清理 pending 对,防止 rescan 后遗留过期僵尸对。 + m.pair.EXPECT().DeletePendingByScriptID(gomock.Any(), int64(7)).Return(nil) + m.es.EXPECT().CountDistinctMatched( + gomock.Any(), int64(7), int64(1), gomock.Any(), gomock.Any(), + ).Return(0, nil) + + err := svc.Scan(ctx, 7, true) + assert.NoError(t, err) +} + +// TestScan_ParseError_MarksFingerprintRow — unparseable JS results in +// parse_status=failed being written to the fingerprint row. +func TestScan_ParseError_MarksFingerprintRow(t *testing.T) { + svc, m, ctx := setupScanMocks(t) + + brokenCode := "function {{{ syntax error" + m.scriptRepo.EXPECT().Find(gomock.Any(), int64(7)).Return(&script_entity.Script{ + ID: 7, UserID: 1, Status: consts.ACTIVE, + }, nil) + m.scriptCodeRepo.EXPECT().FindLatest(gomock.Any(), int64(7), 0, true).Return(&script_entity.Code{ + ID: 11, ScriptID: 7, Code: brokenCode, Createtime: 100, + }, nil) + // No existing row → no hash short-circuit. + m.fingerprint.EXPECT().FindByScriptID(gomock.Any(), int64(7)).Return(nil, nil) + // Early-exit purge: parse failure means any old pending pairs touching + // this script are stale (the new code is unparseable). + m.pair.EXPECT().DeletePendingByScriptID(gomock.Any(), int64(7)).Return(nil) + m.fingerprint.EXPECT().UpdateParseStatus( + gomock.Any(), int64(7), similarity_entity.ParseStatusFailed, gomock.Any(), gomock.Any(), + ).Return(nil) + + err := svc.Scan(ctx, 7, false) + assert.NoError(t, err) +} + +// buildScanTestJS returns a small parseable JS body that produces enough +// fingerprints (>20) for the full scan flow to proceed. +func buildScanTestJS() string { + return strings.Repeat("function f(x) { return x + 1; }\n", 30) +} + +// TestScan_BelowThreshold_NoPair — a candidate with too-few common +// fingerprints yields a Jaccard under the threshold, so neither pair nor +// summary is persisted. +func TestScan_BelowThreshold_NoPair(t *testing.T) { + svc, m, ctx := setupScanMocks(t) + + code := buildScanTestJS() + m.scriptRepo.EXPECT().Find(gomock.Any(), int64(7)).Return(&script_entity.Script{ + ID: 7, UserID: 1, Status: consts.ACTIVE, + }, nil) + m.scriptCodeRepo.EXPECT().FindLatest(gomock.Any(), int64(7), 0, true).Return(&script_entity.Code{ + ID: 11, ScriptID: 7, Code: code, Createtime: 100, + }, nil) + // First scan: no existing fingerprint row. + m.fingerprint.EXPECT().FindByScriptID(gomock.Any(), int64(7)).Return(nil, nil) + m.es.EXPECT().BulkInsert(gomock.Any(), gomock.Any()).Return(nil) + m.fingerprint.EXPECT().Upsert(gomock.Any(), gomock.Any()).Return(nil) + m.es.EXPECT().DeleteOldBatches(gomock.Any(), int64(7), gomock.Any()).Return(nil) + m.es.EXPECT().FindCandidates( + gomock.Any(), int64(7), int64(1), gomock.Any(), gomock.Any(), maxCandidates, + ).Return([]similarity_repo.CandidateHit{ + {ScriptID: 99, UserID: 2, CommonCount: 1}, + }, nil) + // 10b: 批前清理 pending 对。 + m.pair.EXPECT().DeletePendingByScriptID(gomock.Any(), int64(7)).Return(nil) + // Candidate lookup for script 99: large enough that Jaccard = 1/158 ≈ 0.006 + // (well below the 0.30 threshold). + m.fingerprint.EXPECT().FindByScriptID(gomock.Any(), int64(99)).Return(&similarity_entity.Fingerprint{ + ScriptID: 99, + ScriptCodeID: 999, + FingerprintCntEffective: 100, + }, nil) + // Coverage aggregation (spec §4.1 Step 5): 1 distinct matched fingerprint + // out of effective ≈ 59 → coverage ≈ 0.017, below the 0.50 threshold, so + // no summary upsert is expected. + m.es.EXPECT().CountDistinctMatched( + gomock.Any(), int64(7), int64(1), gomock.Any(), gomock.Any(), + ).Return(1, nil) + // pair.Upsert, summary.Upsert, whitelist.IsWhitelisted, scriptCodeRepo.Find + // must NOT be called on the below-threshold branch — gomock's strict mode + // enforces this automatically. + + err := svc.Scan(ctx, 7, false) + assert.NoError(t, err) +} + +// TestScan_OverThreshold_PersistsPair — a candidate whose Jaccard crosses the +// threshold results in a pair row being upserted and a suspect summary. +func TestScan_OverThreshold_PersistsPair(t *testing.T) { + svc, m, ctx := setupScanMocks(t) + + code := buildScanTestJS() + m.scriptRepo.EXPECT().Find(gomock.Any(), int64(7)).Return(&script_entity.Script{ + ID: 7, UserID: 1, Status: consts.ACTIVE, + }, nil) + m.scriptCodeRepo.EXPECT().FindLatest(gomock.Any(), int64(7), 0, true).Return(&script_entity.Code{ + ID: 11, ScriptID: 7, Code: code, Createtime: 100, + }, nil) + m.fingerprint.EXPECT().FindByScriptID(gomock.Any(), int64(7)).Return(nil, nil) + m.es.EXPECT().BulkInsert(gomock.Any(), gomock.Any()).Return(nil) + m.fingerprint.EXPECT().Upsert(gomock.Any(), gomock.Any()).Return(nil) + m.es.EXPECT().DeleteOldBatches(gomock.Any(), int64(7), gomock.Any()).Return(nil) + // effective_a = 59 for this JS. Candidate with CommonCount=50 and + // FingerprintCntEffective=50 → denom = 59+50-50 = 59, jaccard = 50/59 ≈ + // 0.847 (well above the 0.30 threshold). + m.es.EXPECT().FindCandidates( + gomock.Any(), int64(7), int64(1), gomock.Any(), gomock.Any(), maxCandidates, + ).Return([]similarity_repo.CandidateHit{ + {ScriptID: 99, UserID: 2, CommonCount: 50}, + }, nil) + // 10b: 批前清理 pending 对。 + m.pair.EXPECT().DeletePendingByScriptID(gomock.Any(), int64(7)).Return(nil) + m.fingerprint.EXPECT().FindByScriptID(gomock.Any(), int64(99)).Return(&similarity_entity.Fingerprint{ + ScriptID: 99, + ScriptCodeID: 999, + FingerprintCntEffective: 50, + }, nil) + m.whitelist.EXPECT().IsWhitelisted(gomock.Any(), int64(7), int64(99)).Return(false, nil) + m.scriptCodeRepo.EXPECT().Find(gomock.Any(), int64(999)).Return(&script_entity.Code{ + ID: 999, ScriptID: 99, Createtime: 50, + }, nil) + // Coverage aggregation: 50 distinct matched fingerprints / effective 59 ≈ + // 0.847, well above the 0.50 threshold → summary upsert expected. + m.es.EXPECT().CountDistinctMatched( + gomock.Any(), int64(7), int64(1), gomock.Any(), gomock.Any(), + ).Return(50, nil) + m.pair.EXPECT().Upsert(gomock.Any(), gomock.AssignableToTypeOf(&similarity_entity.SimilarPair{})). + DoAndReturn(func(_ context.Context, p *similarity_entity.SimilarPair) error { + assert.Equal(t, similarity_entity.PairStatusPending, p.Status) + return nil + }).Times(1) + m.summary.EXPECT().Upsert(gomock.Any(), gomock.AssignableToTypeOf(&similarity_entity.SuspectSummary{})). + Return(nil).Times(1) + + err := svc.Scan(ctx, 7, false) + assert.NoError(t, err) +} + +// --- Step 2b: async integrity check tests --- + +type stubIntegritySvc struct { + checkResult *IntegrityResult + recordCalled bool + recordScriptID int64 +} + +func (s *stubIntegritySvc) Check(_ context.Context, _ string) *IntegrityResult { + return s.checkResult +} + +func (s *stubIntegritySvc) CheckFast(_ context.Context, _ string) *IntegrityResult { + return s.checkResult +} + +func (s *stubIntegritySvc) IsWhitelisted(_ context.Context, _ int64) (bool, error) { + return false, nil +} + +func (s *stubIntegritySvc) RecordWarning(_ context.Context, scriptID, _, _ int64, _ *IntegrityResult) error { + s.recordCalled = true + s.recordScriptID = scriptID + return nil +} + +func enableIntegrity(m *scanMocks) { + m.cfg.IntegrityEnabled = true + t := true + m.cfg.IntegrityAsyncAutoArchive = &t + m.cfg.IntegrityWarnThreshold = 0.5 + m.cfg.IntegrityBlockThreshold = 0.8 +} + +func TestScan_IntegrityAsync_BlockAutoArchives(t *testing.T) { + svc, m, ctx := setupScanMocks(t) + enableIntegrity(m) + + stub := &stubIntegritySvc{checkResult: &IntegrityResult{ + Score: 0.95, + SubScores: map[string]float64{"cat_a": 1, "cat_b": 1, "cat_c": 1, "cat_d": 1}, + }} + origIntegrity := defaultIntegrity + RegisterIntegrity(stub) + t.Cleanup(func() { RegisterIntegrity(origIntegrity) }) + + script := &script_entity.Script{ID: 7, UserID: 1, Status: consts.ACTIVE, Archive: script_entity.IsActive} + m.scriptRepo.EXPECT().Find(gomock.Any(), int64(7)).Return(script, nil) + m.scriptCodeRepo.EXPECT().FindLatest(gomock.Any(), int64(7), 0, true).Return(&script_entity.Code{ + ID: 11, ScriptID: 7, Code: "var x = 1;", Createtime: 100, + }, nil) + m.integrityWhitelist.EXPECT().IsWhitelisted(gomock.Any(), int64(7)).Return(false, nil) + m.scriptRepo.EXPECT().Update(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, s *script_entity.Script) error { + assert.Equal(t, script_entity.IsArchive, s.Archive) + return nil + }) + // step 3: code_hash short-circuit → no existing row + m.fingerprint.EXPECT().FindByScriptID(gomock.Any(), int64(7)).Return(nil, nil) + // step 5: tiny code → too few fingerprints + m.pair.EXPECT().DeletePendingByScriptID(gomock.Any(), int64(7)).Return(nil) + m.fingerprint.EXPECT().UpdateParseStatus( + gomock.Any(), int64(7), similarity_entity.ParseStatusSkip, "too_few_fingerprints", gomock.Any(), + ).Return(nil) + + err := svc.Scan(ctx, 7, false) + assert.NoError(t, err) + assert.True(t, stub.recordCalled) + assert.Equal(t, int64(7), stub.recordScriptID) +} + +func TestScan_IntegrityAsync_WarnRecordsWarning(t *testing.T) { + svc, m, ctx := setupScanMocks(t) + enableIntegrity(m) + + stub := &stubIntegritySvc{checkResult: &IntegrityResult{ + Score: 0.6, + SubScores: map[string]float64{"cat_a": 0.8, "cat_b": 0, "cat_c": 0, "cat_d": 0}, + }} + origIntegrity := defaultIntegrity + RegisterIntegrity(stub) + t.Cleanup(func() { RegisterIntegrity(origIntegrity) }) + + m.scriptRepo.EXPECT().Find(gomock.Any(), int64(7)).Return(&script_entity.Script{ + ID: 7, UserID: 1, Status: consts.ACTIVE, Archive: script_entity.IsActive, + }, nil) + m.scriptCodeRepo.EXPECT().FindLatest(gomock.Any(), int64(7), 0, true).Return(&script_entity.Code{ + ID: 11, ScriptID: 7, Code: "var x = 1;", Createtime: 100, + }, nil) + m.integrityWhitelist.EXPECT().IsWhitelisted(gomock.Any(), int64(7)).Return(false, nil) + m.fingerprint.EXPECT().FindByScriptID(gomock.Any(), int64(7)).Return(nil, nil) + m.pair.EXPECT().DeletePendingByScriptID(gomock.Any(), int64(7)).Return(nil) + m.fingerprint.EXPECT().UpdateParseStatus( + gomock.Any(), int64(7), similarity_entity.ParseStatusSkip, "too_few_fingerprints", gomock.Any(), + ).Return(nil) + + err := svc.Scan(ctx, 7, false) + assert.NoError(t, err) + assert.True(t, stub.recordCalled, "RecordWarning should be called for warn-zone score") +} + +func TestScan_IntegrityAsync_WhitelistedSkipsCheck(t *testing.T) { + svc, m, ctx := setupScanMocks(t) + enableIntegrity(m) + + stub := &stubIntegritySvc{} + origIntegrity := defaultIntegrity + RegisterIntegrity(stub) + t.Cleanup(func() { RegisterIntegrity(origIntegrity) }) + + m.scriptRepo.EXPECT().Find(gomock.Any(), int64(7)).Return(&script_entity.Script{ + ID: 7, UserID: 1, Status: consts.ACTIVE, + }, nil) + m.scriptCodeRepo.EXPECT().FindLatest(gomock.Any(), int64(7), 0, true).Return(&script_entity.Code{ + ID: 11, ScriptID: 7, Code: "var x = 1;", Createtime: 100, + }, nil) + m.integrityWhitelist.EXPECT().IsWhitelisted(gomock.Any(), int64(7)).Return(true, nil) + m.fingerprint.EXPECT().FindByScriptID(gomock.Any(), int64(7)).Return(nil, nil) + m.pair.EXPECT().DeletePendingByScriptID(gomock.Any(), int64(7)).Return(nil) + m.fingerprint.EXPECT().UpdateParseStatus( + gomock.Any(), int64(7), similarity_entity.ParseStatusSkip, "too_few_fingerprints", gomock.Any(), + ).Return(nil) + + err := svc.Scan(ctx, 7, false) + assert.NoError(t, err) + assert.False(t, stub.recordCalled, "whitelisted scripts should skip integrity check") +} + +func TestScan_IntegrityAsync_BelowWarnThreshold_NoAction(t *testing.T) { + svc, m, ctx := setupScanMocks(t) + enableIntegrity(m) + + stub := &stubIntegritySvc{checkResult: &IntegrityResult{ + Score: 0.2, + SubScores: map[string]float64{"cat_a": 0.2, "cat_b": 0, "cat_c": 0, "cat_d": 0}, + }} + origIntegrity := defaultIntegrity + RegisterIntegrity(stub) + t.Cleanup(func() { RegisterIntegrity(origIntegrity) }) + + m.scriptRepo.EXPECT().Find(gomock.Any(), int64(7)).Return(&script_entity.Script{ + ID: 7, UserID: 1, Status: consts.ACTIVE, + }, nil) + m.scriptCodeRepo.EXPECT().FindLatest(gomock.Any(), int64(7), 0, true).Return(&script_entity.Code{ + ID: 11, ScriptID: 7, Code: "var x = 1;", Createtime: 100, + }, nil) + m.integrityWhitelist.EXPECT().IsWhitelisted(gomock.Any(), int64(7)).Return(false, nil) + m.fingerprint.EXPECT().FindByScriptID(gomock.Any(), int64(7)).Return(nil, nil) + m.pair.EXPECT().DeletePendingByScriptID(gomock.Any(), int64(7)).Return(nil) + m.fingerprint.EXPECT().UpdateParseStatus( + gomock.Any(), int64(7), similarity_entity.ParseStatusSkip, "too_few_fingerprints", gomock.Any(), + ).Return(nil) + + err := svc.Scan(ctx, 7, false) + assert.NoError(t, err) + assert.False(t, stub.recordCalled, "below warn threshold should not record") +} diff --git a/internal/service/similarity_svc/testdata/different_pair/a.js b/internal/service/similarity_svc/testdata/different_pair/a.js new file mode 100644 index 0000000..bc5df20 --- /dev/null +++ b/internal/service/similarity_svc/testdata/different_pair/a.js @@ -0,0 +1,17 @@ +// A canvas drawing utility. +function drawCircle(ctx, x, y, radius, color) { + ctx.beginPath(); + ctx.arc(x, y, radius, 0, 2 * Math.PI); + ctx.fillStyle = color; + ctx.fill(); + ctx.closePath(); +} + +function drawLine(ctx, x1, y1, x2, y2, color, width) { + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = width; + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); +} diff --git a/internal/service/similarity_svc/testdata/different_pair/b.js b/internal/service/similarity_svc/testdata/different_pair/b.js new file mode 100644 index 0000000..7cdc0ee --- /dev/null +++ b/internal/service/similarity_svc/testdata/different_pair/b.js @@ -0,0 +1,17 @@ +// A local storage wrapper with JSON serialization. +function saveItem(key, value) { + var serialized = JSON.stringify(value); + localStorage.setItem(key, serialized); +} + +function loadItem(key) { + var raw = localStorage.getItem(key); + if (raw === null) { + return null; + } + return JSON.parse(raw); +} + +function removeItem(key) { + localStorage.removeItem(key); +} diff --git a/internal/service/similarity_svc/testdata/integrity/borderline/has_vendored_json.js b/internal/service/similarity_svc/testdata/integrity/borderline/has_vendored_json.js new file mode 100644 index 0000000..ac99414 --- /dev/null +++ b/internal/service/similarity_svc/testdata/integrity/borderline/has_vendored_json.js @@ -0,0 +1,20 @@ +// ==UserScript== +// @name Has Vendored JSON +// ==/UserScript== + +// A normal-looking userscript with a small embedded JSON dataset. +const COUNTRIES = { + "US": "United States", "CN": "China", "JP": "Japan", "DE": "Germany", + "FR": "France", "GB": "United Kingdom", "IT": "Italy", "ES": "Spain", + "BR": "Brazil", "IN": "India", "RU": "Russia", "KR": "South Korea", + "CA": "Canada", "AU": "Australia", "MX": "Mexico", "ID": "Indonesia" +}; + +function lookup(code) { + return COUNTRIES[code] || 'Unknown'; +} + +document.querySelectorAll('[data-country]').forEach(function(el) { + const code = el.getAttribute('data-country'); + el.textContent = lookup(code); +}); diff --git a/internal/service/similarity_svc/testdata/integrity/minified/terser_output.js b/internal/service/similarity_svc/testdata/integrity/minified/terser_output.js new file mode 100644 index 0000000..98f144e --- /dev/null +++ b/internal/service/similarity_svc/testdata/integrity/minified/terser_output.js @@ -0,0 +1 @@ +(()=>{"use strict";const o=(o,e)=>o+e,e=(o=>o*o)(7);console.log(o(e,3));const c=[1,2,3,4,5,6,7,8,9,10].map(o=>o*2).filter(o=>o>5);console.log(c);for(let o=0;o<5;o++)console.log("iter",o)})(); diff --git a/internal/service/similarity_svc/testdata/integrity/minified/uglify_output.js b/internal/service/similarity_svc/testdata/integrity/minified/uglify_output.js new file mode 100644 index 0000000..984a14f --- /dev/null +++ b/internal/service/similarity_svc/testdata/integrity/minified/uglify_output.js @@ -0,0 +1 @@ +!function(){"use strict";function n(n){return n*n}function t(n,t){return n+t}var u=n(5),r=t(u,10);console.log(r);for(var e=0;e<10;e++)console.log("iteration",e,n(e));var i={a:1,b:2,c:3,d:4};Object.keys(i).forEach(function(n){console.log(n,i[n])})}(); diff --git a/internal/service/similarity_svc/testdata/integrity/normal/embedded_small_lib.js b/internal/service/similarity_svc/testdata/integrity/normal/embedded_small_lib.js new file mode 100644 index 0000000..8d4df74 --- /dev/null +++ b/internal/service/similarity_svc/testdata/integrity/normal/embedded_small_lib.js @@ -0,0 +1,23 @@ +// ==UserScript== +// @name Embed Small Lib +// @version 1.0 +// ==/UserScript== + +// A short embedded helper — legitimate inlining. +function debounce(fn, wait) { + let timer; + return function(...args) { + clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), wait); + }; +} + +const search = debounce(function(query) { + console.log('searching for', query); +}, 200); + +window.addEventListener('input', function(e) { + if (e.target.matches('input[type=search]')) { + search(e.target.value); + } +}); diff --git a/internal/service/similarity_svc/testdata/integrity/normal/plain_userscript.js b/internal/service/similarity_svc/testdata/integrity/normal/plain_userscript.js new file mode 100644 index 0000000..5b6fe3a --- /dev/null +++ b/internal/service/similarity_svc/testdata/integrity/normal/plain_userscript.js @@ -0,0 +1,16 @@ +// ==UserScript== +// @name Hello World +// @namespace https://example.com +// @version 1.0.0 +// @description A simple userscript that greets the page. +// @match https://example.com/* +// ==/UserScript== + +(function() { + 'use strict'; + const greeting = 'Hello, world!'; + function greet(name) { + console.log(greeting + ' ' + name); + } + greet(document.title); +})(); diff --git a/internal/service/similarity_svc/testdata/integrity/obfuscated/obfuscator_io_level1.js b/internal/service/similarity_svc/testdata/integrity/obfuscated/obfuscator_io_level1.js new file mode 100644 index 0000000..25fdf72 --- /dev/null +++ b/internal/service/similarity_svc/testdata/integrity/obfuscated/obfuscator_io_level1.js @@ -0,0 +1 @@ +var _0xabcd=['log','hello','world','length','forEach'];(function(_0xef12,_0x3456){var _0x789a=function(_0xbcde){while(--_0xbcde){_0xef12['push'](_0xef12['shift']());}};_0x789a(++_0x3456);}(_0xabcd,0x123));var _0x4567=function(_0xfedc,_0xcba9){_0xfedc=_0xfedc-0x0;var _0x8765=_0xabcd[_0xfedc];return _0x8765;};console[_0x4567('0x0')](_0x4567('0x1'),_0x4567('0x2')); diff --git a/internal/service/similarity_svc/testdata/integrity/obfuscated/obfuscator_io_level4.js b/internal/service/similarity_svc/testdata/integrity/obfuscated/obfuscator_io_level4.js new file mode 100644 index 0000000..0fd7652 --- /dev/null +++ b/internal/service/similarity_svc/testdata/integrity/obfuscated/obfuscator_io_level4.js @@ -0,0 +1 @@ +var _0x1a2b=["\x68\x65\x6c\x6c\x6f","\x77\x6f\x72\x6c\x64","\x6c\x6f\x67","\x66\x6f\x6f","\x62\x61\x72","\x62\x61\x7a","\x71\x75\x78","\x71\x75\x75\x78","\x63\x6f\x72\x67\x65","\x67\x72\x61\x75\x6c\x74","\x67\x61\x72\x70\x6c\x79","\x77\x61\x6c\x64\x6f","\x66\x72\x65\x64","\x70\x6c\x75\x67\x68","\x78\x79\x7a\x7a\x79","\x74\x68\x75\x64","\x77\x69\x62\x62\x6c\x65","\x77\x6f\x62\x62\x6c\x65","\x66\x6c\x6f\x62","\x67\x6c\x6f\x72\x70","\x66\x6c\x65\x65\x65\x70","\x6d\x6f\x6f\x6e","\x73\x75\x6e","\x73\x74\x61\x72","\x70\x6c\x61\x6e\x65\x74","\x67\x61\x6c\x61\x78\x79","\x76\x6f\x69\x64","\x65\x61\x72\x74\x68","\x6d\x61\x72\x73","\x76\x65\x6e\x75\x73","\x73\x61\x74\x75\x72\x6e","\x6e\x65\x70\x74\x75\x6e\x65","\x75\x72\x61\x6e\x75\x73","\x70\x6c\x75\x74\x6f","\x6d\x65\x72\x63\x75\x72\x79","\x6a\x75\x70\x69\x74\x65\x72","\x6f\x6e\x65","\x74\x77\x6f","\x74\x68\x72\x65\x65","\x66\x6f\x75\x72","\x66\x69\x76\x65","\x73\x69\x78","\x73\x65\x76\x65\x6e","\x65\x69\x67\x68\x74","\x6e\x69\x6e\x65","\x74\x65\x6e","\x61","\x62","\x63","\x64","\x65","\x66","\x67","\x68","\x69"];eval(function(_0x4d5e,_0x2f3a){return _0x1a2b[_0x4d5e-_0x2f3a];}(0x0,0x0)); diff --git a/internal/service/similarity_svc/testdata/integrity/packed/dean_edwards_packer.js b/internal/service/similarity_svc/testdata/integrity/packed/dean_edwards_packer.js new file mode 100644 index 0000000..0451f3f --- /dev/null +++ b/internal/service/similarity_svc/testdata/integrity/packed/dean_edwards_packer.js @@ -0,0 +1 @@ +eval(function(p,a,c,k,e,d){e=function(c){return c};if(!''.replace(/^/,String)){while(c--){d[c]=k[c]||c}k=[function(e){return d[e]}];e=function(){return'\\w+'};c=1};while(c--){if(k[c]){p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c])}}return p}('0 1=2;3.4(1);',5,5,'var|x|42|console|log'.split('|'),0,{})) diff --git a/internal/service/similarity_svc/testdata/rename_pair/original.js b/internal/service/similarity_svc/testdata/rename_pair/original.js new file mode 100644 index 0000000..c0785c4 --- /dev/null +++ b/internal/service/similarity_svc/testdata/rename_pair/original.js @@ -0,0 +1,13 @@ +function fetchUserData(userId) { + var url = "https://api.example.com/users/" + userId; + var response = httpGet(url); + if (response.status === 200) { + return response.body; + } + return null; +} + +function saveUser(userId, data) { + var url = "https://api.example.com/users/" + userId; + return httpPost(url, data); +} diff --git a/internal/service/similarity_svc/testdata/rename_pair/renamed.js b/internal/service/similarity_svc/testdata/rename_pair/renamed.js new file mode 100644 index 0000000..11b21f1 --- /dev/null +++ b/internal/service/similarity_svc/testdata/rename_pair/renamed.js @@ -0,0 +1,13 @@ +function getPersonRecord(personKey) { + var endpoint = "https://api.example.com/users/" + personKey; + var result = httpGet(endpoint); + if (result.status === 200) { + return result.body; + } + return null; +} + +function persistPerson(personKey, payload) { + var endpoint = "https://api.example.com/users/" + personKey; + return httpPost(endpoint, payload); +} diff --git a/internal/service/similarity_svc/testdata/reorder_pair/original.js b/internal/service/similarity_svc/testdata/reorder_pair/original.js new file mode 100644 index 0000000..d17155e --- /dev/null +++ b/internal/service/similarity_svc/testdata/reorder_pair/original.js @@ -0,0 +1,35 @@ +function add(a, b) { + const x = a + b; + const y = x * 2; + const z = y - 1; + console.log("add result:", x, y, z); + return x; +} + +function multiply(a, b) { + const x = a * b; + const y = x + a; + const z = y + b; + console.log("multiply result:", x, y, z); + return x; +} + +function subtract(a, b) { + const x = a - b; + const y = x + 10; + const z = y - 5; + console.log("subtract result:", x, y, z); + return x; +} + +function divide(a, b) { + if (b === 0) { + console.log("divide by zero"); + return null; + } + const x = a / b; + const y = x * 2; + const z = y + 1; + console.log("divide result:", x, y, z); + return x; +} diff --git a/internal/service/similarity_svc/testdata/reorder_pair/reordered.js b/internal/service/similarity_svc/testdata/reorder_pair/reordered.js new file mode 100644 index 0000000..771ca71 --- /dev/null +++ b/internal/service/similarity_svc/testdata/reorder_pair/reordered.js @@ -0,0 +1,35 @@ +function divide(a, b) { + if (b === 0) { + console.log("divide by zero"); + return null; + } + const x = a / b; + const y = x * 2; + const z = y + 1; + console.log("divide result:", x, y, z); + return x; +} + +function subtract(a, b) { + const x = a - b; + const y = x + 10; + const z = y - 5; + console.log("subtract result:", x, y, z); + return x; +} + +function add(a, b) { + const x = a + b; + const y = x * 2; + const z = y - 1; + console.log("add result:", x, y, z); + return x; +} + +function multiply(a, b) { + const x = a * b; + const y = x + a; + const z = y + b; + console.log("multiply result:", x, y, z); + return x; +} diff --git a/internal/task/consumer/consumer.go b/internal/task/consumer/consumer.go index 8e1dd59..84d2fd4 100644 --- a/internal/task/consumer/consumer.go +++ b/internal/task/consumer/consumer.go @@ -21,6 +21,8 @@ func Consumer(ctx context.Context, cfg *configs.Config) error { &subscribe.Access{}, &subscribe.Report{}, &subscribe.AuditLog{}, + &subscribe.SimilarityScan{}, + subscribe.NewSimilarityPurge(), } for _, v := range subscribers { if err := v.Subscribe(ctx); err != nil { diff --git a/internal/task/consumer/subscribe/similarity_purge.go b/internal/task/consumer/subscribe/similarity_purge.go new file mode 100644 index 0000000..334baa5 --- /dev/null +++ b/internal/task/consumer/subscribe/similarity_purge.go @@ -0,0 +1,35 @@ +package subscribe + +import ( + "context" + + "github.com/cago-frame/cago/pkg/broker/broker" + "github.com/scriptscat/scriptlist/internal/service/similarity_svc" + "github.com/scriptscat/scriptlist/internal/task/producer" +) + +// SimilarityPurge consumes script.delete messages and, when HardDelete=true, +// cascades cleanup across all similarity storage per spec §4.6. +// Soft-delete messages (HardDelete=false, the common case today) are a no-op +// so fingerprints remain queryable as evidence. +type SimilarityPurge struct { + purgeFn func(ctx context.Context, scriptID int64) error +} + +func NewSimilarityPurge() *SimilarityPurge { + return &SimilarityPurge{purgeFn: similarity_svc.PurgeScriptData} +} + +func (s *SimilarityPurge) Subscribe(ctx context.Context) error { + return producer.SubscribeScriptDelete(ctx, s.handle, broker.Group("similarity-purge")) +} + +func (s *SimilarityPurge) handle(ctx context.Context, msg *producer.ScriptDeleteMsg) error { + if msg == nil || msg.Script == nil { + return nil + } + if !msg.HardDelete { + return nil + } + return s.purgeFn(ctx, msg.Script.ID) +} diff --git a/internal/task/consumer/subscribe/similarity_purge_test.go b/internal/task/consumer/subscribe/similarity_purge_test.go new file mode 100644 index 0000000..2246ce6 --- /dev/null +++ b/internal/task/consumer/subscribe/similarity_purge_test.go @@ -0,0 +1,63 @@ +package subscribe + +import ( + "context" + "errors" + "testing" + + "github.com/scriptscat/scriptlist/internal/model/entity/script_entity" + "github.com/scriptscat/scriptlist/internal/task/producer" + "github.com/stretchr/testify/assert" +) + +func TestSimilarityPurgeConsumer_SoftDeleteSkipped(t *testing.T) { + called := false + c := &SimilarityPurge{purgeFn: func(_ context.Context, _ int64) error { + called = true + return nil + }} + err := c.handle(context.Background(), &producer.ScriptDeleteMsg{ + Script: &script_entity.Script{ID: 42}, + HardDelete: false, + }) + assert.NoError(t, err) + assert.False(t, called, "soft delete must not trigger purge per spec §4.6") +} + +func TestSimilarityPurgeConsumer_HardDeleteCascades(t *testing.T) { + var gotID int64 + c := &SimilarityPurge{purgeFn: func(_ context.Context, scriptID int64) error { + gotID = scriptID + return nil + }} + err := c.handle(context.Background(), &producer.ScriptDeleteMsg{ + Script: &script_entity.Script{ID: 42}, + HardDelete: true, + }) + assert.NoError(t, err) + assert.Equal(t, int64(42), gotID) +} + +func TestSimilarityPurgeConsumer_PropagatesError(t *testing.T) { + boom := errors.New("boom") + c := &SimilarityPurge{purgeFn: func(_ context.Context, _ int64) error { + return boom + }} + err := c.handle(context.Background(), &producer.ScriptDeleteMsg{ + Script: &script_entity.Script{ID: 42}, + HardDelete: true, + }) + assert.ErrorIs(t, err, boom) +} + +func TestSimilarityPurgeConsumer_NilScript(t *testing.T) { + c := &SimilarityPurge{purgeFn: func(_ context.Context, _ int64) error { + t.Fatal("purgeFn must not be called") + return nil + }} + err := c.handle(context.Background(), &producer.ScriptDeleteMsg{ + Script: nil, + HardDelete: true, + }) + assert.NoError(t, err) +} diff --git a/internal/task/consumer/subscribe/similarity_scan.go b/internal/task/consumer/subscribe/similarity_scan.go new file mode 100644 index 0000000..be6ed89 --- /dev/null +++ b/internal/task/consumer/subscribe/similarity_scan.go @@ -0,0 +1,41 @@ +package subscribe + +import ( + "context" + + "github.com/cago-frame/cago/pkg/broker/broker" + "github.com/cago-frame/cago/pkg/logger" + "github.com/scriptscat/scriptlist/internal/service/similarity_svc" + "github.com/scriptscat/scriptlist/internal/task/producer" + "go.uber.org/zap" +) + +// SimilarityScan consumes similarity.scan messages and runs ScanSvc.Scan. +type SimilarityScan struct { + // scanFn is overridden in tests; production reads similarity_svc.ScanService(). + scanFn func(ctx context.Context, scriptID int64, force bool) error +} + +func NewSimilarityScan() *SimilarityScan { return &SimilarityScan{} } + +func (s *SimilarityScan) Subscribe(ctx context.Context) error { + return producer.SubscribeSimilarityScan(ctx, s.handle, broker.Group("similarity")) +} + +func (s *SimilarityScan) handle(ctx context.Context, msg *producer.SimilarityScanMsg) error { + fn := s.scanFn + if fn == nil { + fn = func(ctx context.Context, scriptID int64, force bool) error { + return similarity_svc.ScanService().Scan(ctx, scriptID, force) + } + } + if err := fn(ctx, msg.ScriptID, msg.Force); err != nil { + logger.Ctx(ctx).Error("similarity scan failed", + zap.Int64("script_id", msg.ScriptID), + zap.String("source", msg.Source), + zap.Bool("force", msg.Force), + zap.Error(err)) + return err + } + return nil +} diff --git a/internal/task/consumer/subscribe/similarity_scan_test.go b/internal/task/consumer/subscribe/similarity_scan_test.go new file mode 100644 index 0000000..21a3650 --- /dev/null +++ b/internal/task/consumer/subscribe/similarity_scan_test.go @@ -0,0 +1,38 @@ +package subscribe + +import ( + "context" + "testing" + + "github.com/scriptscat/scriptlist/internal/task/producer" + "github.com/stretchr/testify/assert" +) + +func TestSimilarityScanConsumer_DispatchesToSvc(t *testing.T) { + called := false + c := &SimilarityScan{scanFn: func(_ context.Context, scriptID int64, force bool) error { + assert.Equal(t, int64(99), scriptID) + assert.False(t, force) + called = true + return nil + }} + err := c.handle(context.Background(), &producer.SimilarityScanMsg{ScriptID: 99, Source: "publish"}) + assert.NoError(t, err) + assert.True(t, called) +} + +// TestSimilarityScanConsumer_ForwardsForce proves the consumer propagates +// msg.Force through to the service layer — the "从头回填" flow depends on +// this to actually bypass the code_hash short-circuit. +func TestSimilarityScanConsumer_ForwardsForce(t *testing.T) { + var gotForce bool + c := &SimilarityScan{scanFn: func(_ context.Context, _ int64, force bool) error { + gotForce = force + return nil + }} + err := c.handle(context.Background(), &producer.SimilarityScanMsg{ + ScriptID: 1, Source: "backfill", Force: true, + }) + assert.NoError(t, err) + assert.True(t, gotForce) +} diff --git a/internal/task/crontab/crontab.go b/internal/task/crontab/crontab.go index 7767a3f..9f2a970 100644 --- a/internal/task/crontab/crontab.go +++ b/internal/task/crontab/crontab.go @@ -18,7 +18,7 @@ func Crontab(ctx context.Context, cfg *configs.Config) error { if configs.Default().Env == configs.PRE { return nil } - crontab := []Cron{&handler.Statistics{}, &handler.Script{}, &handler.Invite{}, &handler.Deactivate{}} + crontab := []Cron{&handler.Statistics{}, &handler.Script{}, &handler.Invite{}, &handler.Deactivate{}, handler.NewSimilarityStopFpHandler(), handler.NewSimilarityPatrolHandler()} for _, v := range crontab { if err := v.Crontab(cron.Default()); err != nil { return err diff --git a/internal/task/crontab/handler/similarity_patrol.go b/internal/task/crontab/handler/similarity_patrol.go new file mode 100644 index 0000000..f2fc813 --- /dev/null +++ b/internal/task/crontab/handler/similarity_patrol.go @@ -0,0 +1,242 @@ +package handler + +import ( + "context" + "fmt" + "time" + + "github.com/cago-frame/cago/database/redis" + "github.com/cago-frame/cago/pkg/logger" + "github.com/cago-frame/cago/server/cron" + "github.com/scriptscat/scriptlist/configs" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + "github.com/scriptscat/scriptlist/internal/service/similarity_svc" + "github.com/scriptscat/scriptlist/internal/task/producer" + "go.uber.org/zap" +) + +const ( + patrolLockKey = "crontab:similarity:patrol:lock" + backfillLockKey = "crontab:similarity:backfill:lock" + patrolLockTTL = 30 * time.Minute + backfillLockTTL = 2 * time.Hour + patrolPageSize = 200 +) + +// SimilarityPatrolHandler owns the §4.5 patrol + backfill entry points. +// +// - Patrol() runs daily as a cron job, finding scripts whose latest code +// is newer than the last fingerprint scan and publishing NSQ messages +// for them. This is the safety net for dropped publish events. +// +// - RunBackfill() is kicked off by the admin endpoint. It iterates every +// active script starting from `similarity.backfill_cursor`, rate-limited +// by backfill_sleep_ms, and persists the cursor after each batch so a +// crash can resume. +// +// Both modes only publish messages to `similarity.scan`; the actual +// fingerprint work happens in the consumer. Concurrent scans of the same +// script_id are prevented by the Redis scan lock inside similarity_svc.Scan. +// +// All dependencies are function-typed fields so unit tests can substitute +// fakes without standing up Redis / NSQ / MySQL. +type SimilarityPatrolHandler struct { + acquirePatrolLock func(ctx context.Context) (bool, func(), error) + acquireBackfillLock func(ctx context.Context) (bool, func(), error) + listStale func(ctx context.Context, afterID int64, limit int) ([]int64, error) + listFromCursor func(ctx context.Context, cursor int64, limit int) ([]int64, error) + publishScan func(ctx context.Context, scriptID int64, source string, force bool) error + loadState func(ctx context.Context) (*similarity_svc.BackfillState, error) + setCursor func(ctx context.Context, cursor int64) error + finishBackfill func(ctx context.Context, finishedAt int64) error + loadCfg func() *configs.SimilarityConfig +} + +func NewSimilarityPatrolHandler() *SimilarityPatrolHandler { + return &SimilarityPatrolHandler{ + acquirePatrolLock: func(ctx context.Context) (bool, func(), error) { + ok, err := redis.Ctx(ctx).SetNX(patrolLockKey, "1", patrolLockTTL).Result() + if err != nil { + return false, func() {}, err + } + if !ok { + return false, func() {}, nil + } + return true, func() { _, _ = redis.Ctx(ctx).Del(patrolLockKey).Result() }, nil + }, + acquireBackfillLock: func(ctx context.Context) (bool, func(), error) { + ok, err := redis.Ctx(ctx).SetNX(backfillLockKey, "1", backfillLockTTL).Result() + if err != nil { + return false, func() {}, err + } + if !ok { + return false, func() {}, nil + } + return true, func() { _, _ = redis.Ctx(ctx).Del(backfillLockKey).Result() }, nil + }, + listStale: func(ctx context.Context, afterID int64, limit int) ([]int64, error) { + return similarity_repo.PatrolQuery().ListStaleScriptIDs(ctx, afterID, limit) + }, + listFromCursor: func(ctx context.Context, cursor int64, limit int) ([]int64, error) { + return similarity_repo.PatrolQuery().ListScriptIDsFromCursor(ctx, cursor, limit) + }, + publishScan: producer.PublishSimilarityScan, + loadState: similarity_svc.LoadBackfillState, + setCursor: similarity_svc.SetBackfillCursor, + finishBackfill: similarity_svc.FinishBackfill, + loadCfg: configs.Similarity, + } +} + +func (h *SimilarityPatrolHandler) Crontab(c cron.Crontab) error { + // Daily at 03:15 — off-hours, after most publishes settle. + _, err := c.AddFunc("15 3 * * *", h.Patrol) + return err +} + +// Patrol scans for stale scripts and publishes scan messages for them. It +// holds a Redis lock so two instances don't double-publish. Errors for a +// single script don't abort the whole run — we log and continue. +func (h *SimilarityPatrolHandler) Patrol(ctx context.Context) error { + if !h.loadCfg().ScanEnabled { + logger.Ctx(ctx).Info("similarity patrol: scan disabled, skipping") + return nil + } + ok, release, err := h.acquirePatrolLock(ctx) + if err != nil { + logger.Ctx(ctx).Error("similarity patrol: lock error", zap.Error(err)) + return err + } + if !ok { + logger.Ctx(ctx).Info("similarity patrol: lock held, skipping") + return nil + } + defer release() + + var afterID int64 + var total int + for { + ids, err := h.listStale(ctx, afterID, patrolPageSize) + if err != nil { + logger.Ctx(ctx).Error("similarity patrol: query failed", + zap.Int64("after_id", afterID), zap.Error(err)) + return err + } + if len(ids) == 0 { + break + } + for _, id := range ids { + // Patrol never forces — it only picks up scripts whose code + // actually changed, so the code_hash short-circuit wouldn't + // fire anyway. + if err := h.publishScan(ctx, id, "patrol", false); err != nil { + logger.Ctx(ctx).Error("similarity patrol: publish failed", + zap.Int64("script_id", id), zap.Error(err)) + continue + } + total++ + } + afterID = ids[len(ids)-1] + if len(ids) < patrolPageSize { + break + } + } + logger.Ctx(ctx).Info("similarity patrol: done", zap.Int("published", total)) + return nil +} + +// RunBackfill iterates every active script and publishes scan messages. The +// admin endpoint should call this in a detached goroutine because the full +// run takes minutes. Cursor and running flag are persisted in system_config +// so a crash or restart can resume. +// +// force is propagated into each published message's Force field; when true +// the consumer bypasses the code_hash short-circuit so every script is +// genuinely re-extracted. The admin endpoint sets force=true on the "从头 +// 回填" (Reset) flow and false on plain resume runs. +// +// Caller is responsible for having already set backfill_running=true via +// TryAcquireBackfillLock. This function calls finishBackfill in defer so the +// flag is released even on panic or ctx cancellation. +func (h *SimilarityPatrolHandler) RunBackfill(ctx context.Context, force bool) error { + if !h.loadCfg().ScanEnabled { + logger.Ctx(ctx).Warn("similarity backfill: scan disabled, aborting") + _ = h.finishBackfill(ctx, time.Now().Unix()) + return nil + } + // Cross-instance guard in case two pods both call RunBackfill on the + // same running flag. The system_config flag is the primary guard; this + // Redis lock is belt-and-suspenders. + ok, release, err := h.acquireBackfillLock(ctx) + if err != nil { + return fmt.Errorf("similarity backfill: acquire lock failed: %w", err) + } + if !ok { + logger.Ctx(ctx).Info("similarity backfill: lock held by another instance, skipping") + return nil + } + defer release() + defer func() { + if err := h.finishBackfill(ctx, time.Now().Unix()); err != nil { + logger.Ctx(ctx).Error("similarity backfill: finish state write failed", zap.Error(err)) + } + }() + + cfg := h.loadCfg() + sleep := time.Duration(cfg.BackfillSleepMs) * time.Millisecond + batchSize := cfg.BackfillBatchSize + if batchSize <= 0 { + batchSize = 50 + } + + state, err := h.loadState(ctx) + if err != nil { + return err + } + cursor := state.Cursor + // Surface the force flag at start so ops can tell at a glance whether + // this run will bypass the Scan code_hash short-circuit. + logger.Ctx(ctx).Info("similarity backfill: start", + zap.Bool("force", force), + zap.Int64("start_cursor", cursor), + zap.Int("batch_size", batchSize)) + var published int + for { + ids, err := h.listFromCursor(ctx, cursor, batchSize) + if err != nil { + logger.Ctx(ctx).Error("similarity backfill: query failed", + zap.Int64("cursor", cursor), zap.Error(err)) + return err + } + if len(ids) == 0 { + break + } + for _, id := range ids { + if err := h.publishScan(ctx, id, "backfill", force); err != nil { + logger.Ctx(ctx).Error("similarity backfill: publish failed", + zap.Int64("script_id", id), zap.Error(err)) + continue + } + published++ + } + cursor = ids[len(ids)-1] + if err := h.setCursor(ctx, cursor); err != nil { + logger.Ctx(ctx).Error("similarity backfill: cursor write failed", zap.Error(err)) + } + if len(ids) < batchSize { + break + } + // Rate limit NSQ publish so consumers aren't swamped. ctx.Done + // check lets a server shutdown interrupt gracefully. + select { + case <-ctx.Done(): + logger.Ctx(ctx).Info("similarity backfill: context canceled", + zap.Int64("cursor", cursor), zap.Int("published", published)) + return ctx.Err() + case <-time.After(sleep): + } + } + logger.Ctx(ctx).Info("similarity backfill: done", + zap.Int("published", published), zap.Int64("final_cursor", cursor)) + return nil +} diff --git a/internal/task/crontab/handler/similarity_patrol_test.go b/internal/task/crontab/handler/similarity_patrol_test.go new file mode 100644 index 0000000..b7b0e92 --- /dev/null +++ b/internal/task/crontab/handler/similarity_patrol_test.go @@ -0,0 +1,294 @@ +package handler + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/scriptscat/scriptlist/configs" + "github.com/scriptscat/scriptlist/internal/service/similarity_svc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// enabledCfg returns a minimal SimilarityConfig with scan enabled + low +// batch size + zero sleep so tests iterate fast. +func enabledCfg() func() *configs.SimilarityConfig { + return func() *configs.SimilarityConfig { + return &configs.SimilarityConfig{ + ScanEnabled: true, + BackfillBatchSize: 3, + BackfillSleepMs: 0, + } + } +} + +type fakePatrolDeps struct { + staleBatches [][]int64 // successive pages returned by listStale + staleErr error + cursorBatches map[int64][]int64 // cursor → next page for backfill mode + cursorErr error + published []int64 + // publishForces records the force flag each publish was called with, + // in publish order. Used by the RunBackfill force-propagation test. + publishForces []bool + setCursors []int64 + finished bool + loadState *similarity_svc.BackfillState + loadStateErr error +} + +func newFakeHandler(f *fakePatrolDeps) *SimilarityPatrolHandler { + return &SimilarityPatrolHandler{ + acquirePatrolLock: func(_ context.Context) (bool, func(), error) { + return true, func() {}, nil + }, + acquireBackfillLock: func(_ context.Context) (bool, func(), error) { + return true, func() {}, nil + }, + listStale: func(_ context.Context, afterID int64, _ int) ([]int64, error) { + if f.staleErr != nil { + return nil, f.staleErr + } + if len(f.staleBatches) == 0 { + return nil, nil + } + batch := f.staleBatches[0] + f.staleBatches = f.staleBatches[1:] + return batch, nil + }, + listFromCursor: func(_ context.Context, cursor int64, _ int) ([]int64, error) { + if f.cursorErr != nil { + return nil, f.cursorErr + } + return f.cursorBatches[cursor], nil + }, + publishScan: func(_ context.Context, scriptID int64, _ string, force bool) error { + f.published = append(f.published, scriptID) + f.publishForces = append(f.publishForces, force) + return nil + }, + loadState: func(_ context.Context) (*similarity_svc.BackfillState, error) { + if f.loadStateErr != nil { + return nil, f.loadStateErr + } + if f.loadState != nil { + return f.loadState, nil + } + return &similarity_svc.BackfillState{}, nil + }, + setCursor: func(_ context.Context, cursor int64) error { + f.setCursors = append(f.setCursors, cursor) + return nil + }, + finishBackfill: func(_ context.Context, _ int64) error { + f.finished = true + return nil + }, + loadCfg: enabledCfg(), + } +} + +// TestPatrol_ScanDisabled is the early-exit guard. +func TestPatrol_ScanDisabled(t *testing.T) { + f := &fakePatrolDeps{} + h := newFakeHandler(f) + h.loadCfg = func() *configs.SimilarityConfig { + return &configs.SimilarityConfig{ScanEnabled: false} + } + require.NoError(t, h.Patrol(context.Background())) + assert.Empty(t, f.published) +} + +// TestPatrol_LockHeld_Skips mirrors similarity_stop_fp_test: if another +// instance holds the Redis lock, Patrol must no-op without touching repos. +func TestPatrol_LockHeld_Skips(t *testing.T) { + f := &fakePatrolDeps{staleBatches: [][]int64{{1, 2}}} + h := newFakeHandler(f) + h.acquirePatrolLock = func(_ context.Context) (bool, func(), error) { + return false, func() {}, nil + } + require.NoError(t, h.Patrol(context.Background())) + assert.Empty(t, f.published) +} + +// TestPatrol_PublishesForEachStaleID walks two pages (full + partial) and +// checks every id is published exactly once. +func TestPatrol_PublishesForEachStaleID(t *testing.T) { + // Page 1 is full (patrolPageSize items) so the loop continues; page 2 + // is partial so it exits. We use small batches plus a stub where the + // handler asks for patrolPageSize items each time — we return the + // short batch the first call and nothing the second call. Since + // len(ids) < patrolPageSize (200) is true on the first batch, the + // loop exits immediately after one page, which is the common case + // and simpler to assert on. + f := &fakePatrolDeps{staleBatches: [][]int64{{10, 20, 30}}} + h := newFakeHandler(f) + require.NoError(t, h.Patrol(context.Background())) + assert.Equal(t, []int64{10, 20, 30}, f.published) +} + +// TestPatrol_QueryError bubbles up repo errors without publishing anything. +func TestPatrol_QueryError(t *testing.T) { + f := &fakePatrolDeps{staleErr: errors.New("db down")} + h := newFakeHandler(f) + err := h.Patrol(context.Background()) + assert.Error(t, err) + assert.Empty(t, f.published) +} + +// TestPatrol_PublishErrorContinues verifies that a single publish failure +// doesn't stop the whole patrol — we log + count and keep going. +func TestPatrol_PublishErrorContinues(t *testing.T) { + f := &fakePatrolDeps{staleBatches: [][]int64{{1, 2, 3}}} + h := newFakeHandler(f) + publishCalls := 0 + h.publishScan = func(_ context.Context, id int64, _ string, _ bool) error { + publishCalls++ + if id == 2 { + return errors.New("nsq transient") + } + f.published = append(f.published, id) + return nil + } + require.NoError(t, h.Patrol(context.Background())) + assert.Equal(t, 3, publishCalls) + assert.Equal(t, []int64{1, 3}, f.published) // 2 was dropped +} + +// TestRunBackfill_IteratesThroughCursors walks two full batches then a +// partial terminator, verifies that the cursor is persisted after each +// batch and finishBackfill is invoked in defer. +func TestRunBackfill_IteratesThroughCursors(t *testing.T) { + f := &fakePatrolDeps{ + cursorBatches: map[int64][]int64{ + 0: {10, 20, 30}, // full batch + 30: {40, 50, 60}, // full batch + 60: {70}, // partial batch → loop exits + }, + } + h := newFakeHandler(f) + require.NoError(t, h.RunBackfill(context.Background(), false)) + assert.Equal(t, []int64{10, 20, 30, 40, 50, 60, 70}, f.published) + // setCursor called once per batch (3 batches total). + assert.Equal(t, []int64{30, 60, 70}, f.setCursors) + assert.True(t, f.finished) +} + +// TestRunBackfill_ResumesFromPersistedCursor proves that a crash midway +// resumes on the next invocation — the loaded cursor determines the first +// page. +func TestRunBackfill_ResumesFromPersistedCursor(t *testing.T) { + f := &fakePatrolDeps{ + loadState: &similarity_svc.BackfillState{Cursor: 30}, + cursorBatches: map[int64][]int64{ + 30: {40}, // partial batch (<3 items) → loop exits + }, + } + h := newFakeHandler(f) + require.NoError(t, h.RunBackfill(context.Background(), false)) + assert.Equal(t, []int64{40}, f.published) + assert.Equal(t, []int64{40}, f.setCursors) + assert.True(t, f.finished) +} + +// TestRunBackfill_ScanDisabled guard: finishBackfill still fires so the +// caller's TryAcquireBackfillLock gets released even though no work ran. +func TestRunBackfill_ScanDisabled(t *testing.T) { + f := &fakePatrolDeps{} + h := newFakeHandler(f) + h.loadCfg = func() *configs.SimilarityConfig { + return &configs.SimilarityConfig{ScanEnabled: false} + } + require.NoError(t, h.RunBackfill(context.Background(), false)) + assert.True(t, f.finished) + assert.Empty(t, f.published) +} + +// TestRunBackfill_LockHeld_Skips ensures the Redis cross-instance guard +// exits without advancing the cursor or calling finishBackfill. +func TestRunBackfill_LockHeld_Skips(t *testing.T) { + f := &fakePatrolDeps{ + cursorBatches: map[int64][]int64{0: {1, 2, 3}}, + } + h := newFakeHandler(f) + h.acquireBackfillLock = func(_ context.Context) (bool, func(), error) { + return false, func() {}, nil + } + require.NoError(t, h.RunBackfill(context.Background(), false)) + assert.Empty(t, f.published) + assert.False(t, f.finished) +} + +// TestRunBackfill_LoadStateError surfaces errors from system_config reads +// and still releases the Redis lock (via defer) and running flag. +func TestRunBackfill_LoadStateError(t *testing.T) { + f := &fakePatrolDeps{loadStateErr: errors.New("cfg table broken")} + h := newFakeHandler(f) + err := h.RunBackfill(context.Background(), false) + assert.Error(t, err) + assert.True(t, f.finished, "finishBackfill must fire in defer on error paths") +} + +// TestRunBackfill_ForcePropagates proves that force=true is stamped onto +// every published message so the consumer-side Scan bypasses its code_hash +// short-circuit. This is what makes admin "从头回填" actually rescan. +func TestRunBackfill_ForcePropagates(t *testing.T) { + f := &fakePatrolDeps{ + cursorBatches: map[int64][]int64{ + 0: {10, 20}, // partial batch → one-shot loop + }, + } + h := newFakeHandler(f) + require.NoError(t, h.RunBackfill(context.Background(), true)) + assert.Equal(t, []int64{10, 20}, f.published) + assert.Equal(t, []bool{true, true}, f.publishForces) +} + +// TestRunBackfill_NoForceDefault mirrors the above with force=false so a +// regression can't silently make the backfill always force. +func TestRunBackfill_NoForceDefault(t *testing.T) { + f := &fakePatrolDeps{ + cursorBatches: map[int64][]int64{ + 0: {10, 20}, + }, + } + h := newFakeHandler(f) + require.NoError(t, h.RunBackfill(context.Background(), false)) + assert.Equal(t, []bool{false, false}, f.publishForces) +} + +// TestRunBackfill_ContextCancelled interrupts mid-loop — simulates graceful +// shutdown. We need a non-zero sleep so the select on ctx.Done actually +// fires during the rate-limit step. +func TestRunBackfill_ContextCancelled(t *testing.T) { + f := &fakePatrolDeps{ + cursorBatches: map[int64][]int64{ + 0: {10, 20, 30}, // full batch → enters rate-limit sleep + 30: {40, 50, 60}, + }, + } + h := newFakeHandler(f) + h.loadCfg = func() *configs.SimilarityConfig { + return &configs.SimilarityConfig{ + ScanEnabled: true, + BackfillBatchSize: 3, + BackfillSleepMs: 200, + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Cancel during the first sleep window. + go func() { + time.Sleep(30 * time.Millisecond) + cancel() + }() + + err := h.RunBackfill(ctx, false) + assert.ErrorIs(t, err, context.Canceled) + assert.True(t, f.finished) + // First batch should have published before the cancel. + assert.Equal(t, []int64{10, 20, 30}, f.published) +} diff --git a/internal/task/crontab/handler/similarity_stop_fp.go b/internal/task/crontab/handler/similarity_stop_fp.go new file mode 100644 index 0000000..80b323d --- /dev/null +++ b/internal/task/crontab/handler/similarity_stop_fp.go @@ -0,0 +1,104 @@ +package handler + +import ( + "context" + "fmt" + "time" + + "github.com/cago-frame/cago/database/redis" + "github.com/cago-frame/cago/pkg/logger" + "github.com/cago-frame/cago/server/cron" + "github.com/scriptscat/scriptlist/configs" + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + "github.com/scriptscat/scriptlist/internal/service/similarity_svc" + "go.uber.org/zap" +) + +const ( + stopFpLockKey = "crontab:similarity:stop_fp_refresh:lock" + stopFpLockTTL = 5 * time.Minute +) + +// SimilarityStopFpHandler refreshes the Redis stop-fingerprint set from the +// ES aggregation. Function-typed fields allow unit tests to substitute fakes +// for Redis / config / repo dependencies. +type SimilarityStopFpHandler struct { + acquireLock func(ctx context.Context) (acquired bool, release func(), err error) + writeStopFps func(ctx context.Context, fps []string) error + aggregate func(ctx context.Context, cutoff int) ([]similarity_repo.StopFpEntry, error) + loadCfg func() (cutoff int) +} + +func NewSimilarityStopFpHandler() *SimilarityStopFpHandler { + return &SimilarityStopFpHandler{ + acquireLock: func(ctx context.Context) (bool, func(), error) { + ok, err := redis.Ctx(ctx).SetNX(stopFpLockKey, "1", stopFpLockTTL).Result() + if err != nil { + return false, func() {}, err + } + if !ok { + return false, func() {}, nil + } + return true, func() { _, _ = redis.Ctx(ctx).Del(stopFpLockKey).Result() }, nil + }, + writeStopFps: func(ctx context.Context, fps []string) error { + c := redis.Ctx(ctx) + if _, err := c.Del(similarity_svc.StopFpRedisKey).Result(); err != nil { + return err + } + if len(fps) == 0 { + return nil + } + args := make([]any, 0, len(fps)) + for _, fp := range fps { + args = append(args, fp) + } + return c.Client.SAdd(ctx, similarity_svc.StopFpRedisKey, args...).Err() + }, + aggregate: func(ctx context.Context, cutoff int) ([]similarity_repo.StopFpEntry, error) { + return similarity_repo.FingerprintES().AggregateStopFp(ctx, cutoff) + }, + loadCfg: func() int { return configs.Similarity().StopFpDfCutoff }, + } +} + +// Crontab registers the stop-fp refresh using the similarity.stop_fp_refresh_sec +// config value, formatted as an @every duration (robfig/cron syntax). +func (h *SimilarityStopFpHandler) Crontab(c cron.Crontab) error { + sec := configs.Similarity().StopFpRefreshSec + spec := fmt.Sprintf("@every %ds", sec) + _, err := c.AddFunc(spec, h.Refresh) + return err +} + +func (h *SimilarityStopFpHandler) Refresh(ctx context.Context) error { + acquired, release, err := h.acquireLock(ctx) + if err != nil { + logger.Ctx(ctx).Error("stop_fp refresh: lock failed", zap.Error(err)) + return err + } + if !acquired { + logger.Ctx(ctx).Info("stop_fp refresh: lock held by peer, skipping") + return nil + } + defer release() + + cutoff := h.loadCfg() + entries, err := h.aggregate(ctx, cutoff) + if err != nil { + logger.Ctx(ctx).Error("stop_fp refresh: aggregate failed", zap.Error(err)) + return err + } + fps := make([]string, 0, len(entries)) + for _, e := range entries { + fps = append(fps, e.Fingerprint) + } + if err := h.writeStopFps(ctx, fps); err != nil { + logger.Ctx(ctx).Error("stop_fp refresh: redis write failed", zap.Error(err)) + return err + } + logger.Ctx(ctx).Info("stop_fp refresh ok", + zap.Int("count", len(fps)), + zap.Int("cutoff", cutoff)) + return nil +} diff --git a/internal/task/crontab/handler/similarity_stop_fp_test.go b/internal/task/crontab/handler/similarity_stop_fp_test.go new file mode 100644 index 0000000..8ee1878 --- /dev/null +++ b/internal/task/crontab/handler/similarity_stop_fp_test.go @@ -0,0 +1,48 @@ +package handler + +import ( + "context" + "testing" + + "github.com/scriptscat/scriptlist/internal/repository/similarity_repo" + "github.com/stretchr/testify/assert" +) + +func TestSimilarityStopFp_AggregatesAndWrites(t *testing.T) { + h := &SimilarityStopFpHandler{ + acquireLock: func(ctx context.Context) (bool, func(), error) { + return true, func() {}, nil + }, + writeStopFps: func(ctx context.Context, fps []string) error { + assert.Equal(t, []string{"abc", "def"}, fps) + return nil + }, + aggregate: func(ctx context.Context, cutoff int) ([]similarity_repo.StopFpEntry, error) { + assert.Equal(t, 50, cutoff) + return []similarity_repo.StopFpEntry{ + {Fingerprint: "abc", DocCount: 100}, + {Fingerprint: "def", DocCount: 75}, + }, nil + }, + loadCfg: func() (cutoff int) { return 50 }, + } + err := h.Refresh(context.Background()) + assert.NoError(t, err) +} + +func TestSimilarityStopFp_LockHeld_ReturnsNil(t *testing.T) { + called := false + h := &SimilarityStopFpHandler{ + acquireLock: func(ctx context.Context) (bool, func(), error) { + return false, func() {}, nil + }, + writeStopFps: func(ctx context.Context, fps []string) error { + called = true + return nil + }, + loadCfg: func() (cutoff int) { return 50 }, + } + err := h.Refresh(context.Background()) + assert.NoError(t, err) + assert.False(t, called) +} diff --git a/internal/task/producer/script.go b/internal/task/producer/script.go index 3efa883..b486680 100644 --- a/internal/task/producer/script.go +++ b/internal/task/producer/script.go @@ -101,6 +101,11 @@ type ScriptDeleteMsg struct { Script *script_entity.Script `json:"script"` Operator `json:",inline"` Reason string `json:"reason,omitempty"` + // HardDelete 区分软删除和物理删除。软删除(默认)只是将 status 置为 + // DELETE,相似度系统保留指纹作为后续脚本抄袭它的证据(spec §4.6)。 + // 物理删除场景下,下游消费者(如 similarity_svc)必须级联清理所有外部 + // 存储(ES 指纹、fingerprint 行、pair 行、suspect_summary 行)。 + HardDelete bool `json:"hard_delete,omitempty"` } func PublishScriptDelete(ctx context.Context, msg *ScriptDeleteMsg) error { diff --git a/internal/task/producer/similarity.go b/internal/task/producer/similarity.go new file mode 100644 index 0000000..505fb7b --- /dev/null +++ b/internal/task/producer/similarity.go @@ -0,0 +1,52 @@ +package producer + +import ( + "context" + "encoding/json" + + "github.com/cago-frame/cago/pkg/broker" + broker2 "github.com/cago-frame/cago/pkg/broker/broker" +) + +// SimilarityScanMsg is sent by script_svc / patrol / backfill to request a scan. +// Per spec §4.2 the message carries only the script_id; consumer reads the +// latest code at scan time to avoid version-ordering bugs. +// +// Force=true tells the consumer to bypass the code_hash short-circuit in +// similarity_svc.Scan. It is set by the admin "从头回填" flow so an explicit +// Reset triggers a true full rescan instead of a no-op idempotent walk. +type SimilarityScanMsg struct { + ScriptID int64 `json:"script_id"` + Source string `json:"source"` // "publish" | "update" | "patrol" | "backfill" + Force bool `json:"force,omitempty"` // true → bypass code_hash short-circuit +} + +// PublishSimilarityScan publishes a scan request. force=true makes the +// consumer bypass the code_hash short-circuit (used by admin "从头回填"); +// all other callers pass false so idempotent republishes stay cheap. +func PublishSimilarityScan(ctx context.Context, scriptID int64, source string, force bool) error { + body, err := json.Marshal(&SimilarityScanMsg{ScriptID: scriptID, Source: source, Force: force}) + if err != nil { + return err + } + return broker.Default().Publish(ctx, SimilarityScanTopic, &broker2.Message{Body: body}) +} + +func ParseSimilarityScanMsg(msg *broker2.Message) (*SimilarityScanMsg, error) { + out := &SimilarityScanMsg{} + if err := json.Unmarshal(msg.Body, out); err != nil { + return nil, err + } + return out, nil +} + +func SubscribeSimilarityScan(ctx context.Context, fn func(ctx context.Context, msg *SimilarityScanMsg) error, opts ...broker2.SubscribeOption) error { + _, err := broker.Default().Subscribe(ctx, SimilarityScanTopic, func(ctx context.Context, ev broker2.Event) error { + m, err := ParseSimilarityScanMsg(ev.Message()) + if err != nil { + return err + } + return fn(ctx, m) + }, opts...) + return err +} diff --git a/internal/task/producer/similarity_test.go b/internal/task/producer/similarity_test.go new file mode 100644 index 0000000..1439293 --- /dev/null +++ b/internal/task/producer/similarity_test.go @@ -0,0 +1,20 @@ +package producer + +import ( + "encoding/json" + "testing" + + broker2 "github.com/cago-frame/cago/pkg/broker/broker" + "github.com/stretchr/testify/assert" +) + +func TestSimilarityScanMsg_RoundTrip(t *testing.T) { + msg := &SimilarityScanMsg{ScriptID: 42, Source: "publish"} + body, err := json.Marshal(msg) + assert.NoError(t, err) + + parsed, err := ParseSimilarityScanMsg(&broker2.Message{Body: body}) + assert.NoError(t, err) + assert.Equal(t, int64(42), parsed.ScriptID) + assert.Equal(t, "publish", parsed.Source) +} diff --git a/internal/task/producer/topic.go b/internal/task/producer/topic.go index 2315d8b..af3bb8c 100644 --- a/internal/task/producer/topic.go +++ b/internal/task/producer/topic.go @@ -15,4 +15,6 @@ const ( ReportCreateTopic = "report.create" // 创建举报 ReportCommentCreateTopic = "report.comment.create" // 举报评论 + + SimilarityScanTopic = "similarity.scan" // 代码相似度扫描 ) diff --git a/migrations/20260414.go b/migrations/20260414.go new file mode 100644 index 0000000..dae7ed5 --- /dev/null +++ b/migrations/20260414.go @@ -0,0 +1,34 @@ +package migrations + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "github.com/scriptscat/scriptlist/internal/model/entity/similarity_entity" + "gorm.io/gorm" +) + +// T20260414 为代码相似度检测与完整性审查创建相关表 +func T20260414() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "T20260414", + Migrate: func(tx *gorm.DB) error { + return tx.AutoMigrate( + &similarity_entity.Fingerprint{}, + &similarity_entity.SimilarPair{}, + &similarity_entity.SuspectSummary{}, + &similarity_entity.SimilarityWhitelist{}, + &similarity_entity.IntegrityWhitelist{}, + &similarity_entity.IntegrityReview{}, + ) + }, + Rollback: func(tx *gorm.DB) error { + return tx.Migrator().DropTable( + "pre_script_fingerprint", + "pre_script_similar_pair", + "pre_script_suspect_summary", + "pre_script_similarity_whitelist", + "pre_script_integrity_whitelist", + "pre_script_integrity_review", + ) + }, + } +} diff --git a/migrations/init.go b/migrations/init.go index cf2d4d6..af9f464 100644 --- a/migrations/init.go +++ b/migrations/init.go @@ -57,6 +57,7 @@ func RunMigrations(db *gorm.DB) error { T20260323, T20260331, T20260331B, + T20260414, ) }