diff --git a/ROADMAP.md b/ROADMAP.md index 2bd3d35d..715e9692 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -224,6 +224,7 @@ Order = user-visible value first; each ships with backend + UI + tests. - [x] Per-user blob relocation (upstream #2729/misc) — `RelocateEntityService` gains a third scope `src_user_id` (mutually exclusive with `entity_ids`/`src_policy_id`); `NewRelocateUserTask` selects entities by `created_by` with the same cursor-resumable transfer path; admin user editor gains a "Relocate files" button opening the relocate dialog prefilled with the user scope; en+zh locales - [x] Tencent Captcha (upstream #2178) — `captcha_type=tcaptcha` now performs real verification: `pkg/tcaptcha` calls Tencent Cloud `DescribeCaptchaResult` with full TC3-HMAC-SHA256 request signing (CaptchaAppId/AppSecretKey + SecretId/SecretKey, CaptchaType 9, client IP propagated); login/register/forgot-password flows emit `{ticket, randstr}` from the TCaptcha.js popup widget via a new `TCaptcha` verify-button component; admin Captcha section gains the provider option + four credential fields; en+zh locales - [x] Localized admin strings (#25/#2691) — `setting.Provider.Localized` resolves any `_i18n` JSON map by language tag (exact → bare primary subtag → wildcard `*` → base value); `SiteBasicLocalized` covers site name/title/description; consumed by site config, announcement endpoint, share-preview OG tags, index.html placeholders, WOPI breadcrumb, and email templates (recipient language); SKU gains `name_i18n`/`des_i18n` columns resolved per buyer language in the shop; admin gets a reusable `LocalizedFields` accordion (per-language inputs) wired into site name/description/announcement and SKU name/description; en+zh locales +- [x] Weighted policy selection (upstream #2178 item 2) — `GroupSetting.WeightedPolicies` spreads uploads across the group's allowed policies by free capacity: `pickByFreeCapacity` picks the member with the most remaining `MaxTotalSize` headroom that fits the file (uncapped/suspended members not weighed); explicit directory/user preferences still win; size-aware `getPreferredPolicyForSize` wired into both upload paths; admin group editor gains a switch, en+zh locales ## 6. Phase D — desktop, all platforms diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json index 881aa98d..87ea6dcf 100644 --- a/frontend/public/locales/en-US/dashboard.json +++ b/frontend/public/locales/en-US/dashboard.json @@ -1427,6 +1427,8 @@ "editGroup": "Edit {{group}}", "switchablePolicies": "Switchable storage policies", "switchablePoliciesDes": "Policies members of this group can freely switch to. The default policy above is always available.", + "weightedPolicies": "Weighted policy selection", + "weightedPoliciesDes": "When enabled, uploads without an explicit policy preference are routed to the allowed policy with the most free capacity that fits the file. Only policies with a total capacity limit are weighed.", "sharePublicList": "Public share directory", "sharePublicListDes": "When enabled, users can opt their share links into the public directory (Discover page). Password-protected shares cannot be listed." }, diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json index 5b9acb6d..89f1c28b 100644 --- a/frontend/public/locales/zh-CN/dashboard.json +++ b/frontend/public/locales/zh-CN/dashboard.json @@ -1427,6 +1427,8 @@ "editGroup": "编辑 {{group}}", "switchablePolicies": "可切换存储策略", "switchablePoliciesDes": "该组成员可以自由切换使用的存储策略。上方默认策略始终可用。", + "weightedPolicies": "按容量加权选择策略", + "weightedPoliciesDes": "启用后,未指定策略偏好的上传会被分配到剩余容量最多且可容纳该文件的可切换存储策略。仅参与设置了总容量限制的策略。", "sharePublicList": "公开分享目录", "sharePublicListDes": "开启后,用户可以将分享链接收录到公开目录(发现页)。带密码的分享无法被收录。" }, diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index adc8585f..848c6d0a 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -68,6 +68,7 @@ export interface GroupSetting { default_pinned?: number[]; allowed_nodes?: number[]; allow_select_node?: boolean; + weighted_policies?: boolean; } export interface AdminListGroupResponse { diff --git a/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx b/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx index 36278e62..a9b416c1 100644 --- a/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx +++ b/frontend/src/component/Admin/Group/EditGroup/BasicInfoSection.tsx @@ -126,6 +126,25 @@ const BasicInfoSection = () => { /> {t("group.switchablePoliciesDes")} + + + + setGroup((p: GroupEnt) => ({ + ...p, + settings: { ...p.settings, weighted_policies: e.target.checked ? true : undefined }, + })) + } + /> + } + label={t("group.weightedPolicies")} + /> + {t("group.weightedPoliciesDes")} + + = size && free > bestFree { + best, bestFree = p, free + } + } + return best +} + func (f *DBFS) getFileByPath(ctx context.Context, navigator Navigator, path *fs.URI) (*File, error) { file, err := navigator.To(ctx, path) if err != nil && errors.Is(err, ErrFsNotInitialized) { diff --git a/pkg/filemanager/fs/dbfs/pick_policy_test.go b/pkg/filemanager/fs/dbfs/pick_policy_test.go index f663d30c..61c8c96f 100644 --- a/pkg/filemanager/fs/dbfs/pick_policy_test.go +++ b/pkg/filemanager/fs/dbfs/pick_policy_test.go @@ -99,13 +99,13 @@ func TestPickPolicyPrecedence(t *testing.T) { t.Run("directory marker wins over user preference", func(t *testing.T) { setPreferredMarker(t, client, dir, f.hasher, a.ID) - got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed) + got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed, 0) require.Equal(t, a.ID, got.ID) }) t.Run("user preference wins over group default", func(t *testing.T) { client.Metadata.Delete().ExecX(schema.SkipSoftDelete(ctx)) - got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed) + got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed, 0) require.Equal(t, b.ID, got.ID) }) @@ -116,14 +116,14 @@ func TestPickPolicyPrecedence(t *testing.T) { SetStatus(storagepolicy.StatusActive).SaveX(ctx) setPreferredMarker(t, client, root, f.hasher, a.ID) setPreferredMarker(t, client, dir, f.hasher, outside.ID) - got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed) + got := f.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed, 0) require.Equal(t, b.ID, got.ID) }) t.Run("other user's tree ignores user preference", func(t *testing.T) { other := client.User.Create().SetEmail("x@example.com").SetNick("x").SetGroup(group).SaveX(ctx) otherFs := pickPolicyDBFS(t, client, other) - got := otherFs.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed) + got := otherFs.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), owner, allowed, 0) require.Equal(t, def.ID, got.ID) }) @@ -132,7 +132,68 @@ func TestPickPolicyPrecedence(t *testing.T) { SetGroup(group).SetSettings(&types.UserSetting{}).SaveX(ctx) ownerNoPref.SetGroup(group) fsNoPref := pickPolicyDBFS(t, client, ownerNoPref) - got := fsNoPref.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), ownerNoPref, allowed) + got := fsNoPref.pickPolicy(ctx, freshChain(t, client, root.ID, dir.ID), ownerNoPref, allowed, 0) + require.Equal(t, def.ID, got.ID) + }) +} + +func TestPickPolicyWeightedCapacity(t *testing.T) { + client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") + t.Cleanup(func() { require.NoError(t, client.Close()) }) + ctx := context.Background() + + mk := func(name string, cap int64) *ent.StoragePolicy { + return client.StoragePolicy.Create().SetName(name).SetType("local"). + SetStatus(storagepolicy.StatusActive). + SetSettings(&types.PolicySetting{MaxTotalSize: cap}).SaveX(ctx) + } + def := mk("def", 0) + big := mk("big", 1000) + small := mk("small", 100) + uncapped := client.StoragePolicy.Create().SetName("uncapped").SetType("local"). + SetStatus(storagepolicy.StatusActive). + SetSettings(&types.PolicySetting{}).SaveX(ctx) + suspended := client.StoragePolicy.Create().SetName("sus").SetType("local"). + SetStatus(storagepolicy.StatusSuspended). + SetSettings(&types.PolicySetting{MaxTotalSize: 99999}).SaveX(ctx) + + group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}). + SetStoragePolicies(def).AddAllowedPolicies(big, small, uncapped, suspended). + SetSettings(&types.GroupSetting{WeightedPolicies: true}).SaveX(ctx) + owner := client.User.Create().SetEmail("o@example.com").SetNick("o"). + SetGroup(group).SetSettings(&types.UserSetting{}).SaveX(ctx) + root := client.File.Create().SetName(inventory.RootFolderName). + SetType(int(types.FileTypeFolder)).SetOwner(owner).SaveX(ctx) + + allowed := []*ent.StoragePolicy{big, small, uncapped, suspended, def} + f := pickPolicyDBFS(t, client, owner) + owner.SetGroup(group) + + t.Run("most free capacity wins", func(t *testing.T) { + got := f.pickPolicy(ctx, wrapChain(root), owner, allowed, 10) + require.Equal(t, big.ID, got.ID) + }) + + t.Run("skips policy without headroom for the file", func(t *testing.T) { + got := f.pickPolicy(ctx, wrapChain(root), owner, allowed, 500) + require.Equal(t, big.ID, got.ID) // small only has 100 free + got = f.pickPolicy(ctx, wrapChain(root), owner, allowed, 2000) + require.Equal(t, def.ID, got.ID) // nothing fits -> group default + }) + + t.Run("usage fills shift the pick", func(t *testing.T) { + client.Entity.Create().SetType(int(types.EntityTypeVersion)). + SetSource("cloudreve/data/big/" + t.Name()).SetSize(950). + SetReferenceCount(1).SetCreatedBy(owner.ID). + SetStoragePolicyEntities(big.ID).SaveX(ctx) + got := f.pickPolicy(ctx, wrapChain(root), owner, allowed, 10) + require.Equal(t, small.ID, got.ID) // big now has only 50 free + }) + + t.Run("flag off keeps group default", func(t *testing.T) { + group.Update().SetSettings(&types.GroupSetting{}).ExecX(ctx) + owner.SetGroup(client.Group.GetX(ctx, group.ID)) + got := f.pickPolicy(ctx, wrapChain(root), owner, allowed, 10) require.Equal(t, def.ID, got.ID) }) } diff --git a/pkg/filemanager/fs/dbfs/upload.go b/pkg/filemanager/fs/dbfs/upload.go index 10ff5694..ec42aed4 100644 --- a/pkg/filemanager/fs/dbfs/upload.go +++ b/pkg/filemanager/fs/dbfs/upload.go @@ -46,7 +46,7 @@ func (f *DBFS) PreValidateUpload(ctx context.Context, dst *fs.URI, files ...fs.P } // Get parent folder storage policy and performs validation - policy, err := f.getPreferredPolicy(ctx, dstFile) + policy, err := f.getPreferredPolicyForSize(ctx, dstFile, total) if err != nil { return err } @@ -146,7 +146,7 @@ func (f *DBFS) PrepareUpload(ctx context.Context, req *fs.UploadRequest, opts .. // explicitly instead of inheriting the ancestor's preference. policy, err = f.storagePolicyClient.GetPolicyByID(ctx, req.Props.PreferredStoragePolicy) } else { - policy, err = f.getPreferredPolicy(ctx, ancestor) + policy, err = f.getPreferredPolicyForSize(ctx, ancestor, req.Props.Size) } if err != nil { return nil, err