mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-16 06:59:02 +08:00
refactor: remove unused private Go helpers
This commit is contained in:
@@ -86,6 +86,17 @@
|
||||
- 新增 15 项真实 composable 行为测试,覆盖两个入口、接口路由、缓存、引用抽屉、请求竞争、计时和 Vue 挂载/卸载生命周期。
|
||||
- `scripts/verify_frontend_pr.sh` 通过:948 项测试通过,1 项原有浏览器场景测试跳过,类型检查和生产构建通过。嵌入消息 template/style 与本批基线逐字节一致;未做浏览器视觉验收,未修改或测试后端。
|
||||
|
||||
## 第五批已实施:Go 未引用私有函数
|
||||
|
||||
基于第四批合入后的 `d645334d0`,逐包核实原有 16 个候选,全部确认无调用;同时删除仅被候选使用的 `collectTableAliases` 与 `escapeDoubleQuotes`。共移除 18 个私有函数和 3 个失效 import,生产 Go 源码净减少 235 行。
|
||||
|
||||
- 使用 Go AST 检查候选所属 12 个包目录中的 894 个 Go 文件,包括测试、带构建标签和平台后缀的源码;删除集合以外没有对应标识符引用。全仓搜索同时检查文档、脚本和特殊链接引用。
|
||||
- 对修改文件逐函数比较:453 个保留函数的签名与函数体完全一致。Qdrant 的 `tokenizeQuery`、Agent 工具包的 `formatFileSize`、JSON 解析包的 `formatValue` 等同名活跃实现继续保留。
|
||||
- 更新分块配置相关注释和开发文档,指向仍在使用的 `buildSplitterConfigFromChunking` / `NormalizeSplitterConfig`;迁移历史注释保留旧名称。
|
||||
- 13 个受影响包执行 `go test -count=1`:12 个包通过,微信适配包无测试文件但编译通过;3,529 项顶层测试通过、1 项原有测试跳过,计入子测试为 5,274 项通过。跳过项为需要可替换连接探测或真实后端的向量存储创建测试。
|
||||
- `integration,e2b_integration,docker_integration` 标签下的 sandbox/tools 测试代码编译通过,未执行需要外部服务的集成测试。未修改前端,未运行前端或浏览器检查。
|
||||
- 本批只删除无调用代码,复用现有包测试和编译检查,没有添加仅断言函数不存在的测试。
|
||||
|
||||
## 后续优先级
|
||||
|
||||
| 优先级 | 证据 | 建议边界 | 验证重点 |
|
||||
@@ -100,24 +111,24 @@
|
||||
|
||||
### 后端重复的具体判断
|
||||
|
||||
- Qdrant 与 Weaviate 的 `tokenizeQuery` 函数体相同,但只有 Qdrant 的关键字检索调用它;Weaviate 使用 `WithQuery(params.Query)`。后续应验证并删除 Weaviate 的失效函数,而非为了它增加共享分词层。
|
||||
- Qdrant 与 Weaviate 的 `tokenizeQuery` 函数体相同,但只有 Qdrant 的关键字检索调用它;Weaviate 使用 `WithQuery(params.Query)`。第五批已删除 Weaviate 的失效函数,保留 Qdrant 实现。
|
||||
- IMA、语雀的 `sanitizeFileName` 函数体相同,可以共享;RSS 的近似实现另外处理首尾空白和换行,合并时必须保留这些差异。
|
||||
- Milvus/Qdrant/Weaviate 的结果映射体相似,但接收不同后端类型。当前各约 17 行,为此引入通用接口或泛型转换层未必减少维护成本。
|
||||
- 终端和桌面 WebSocket ticket handler 重复检查会话、用户、租户和 token,但签发方式不同。后续可共享前置身份校验,保留 ticket 和连接生命周期各自的契约。
|
||||
- 资源权限已经有 `internal/application/access`;继续沿已有授权边界收敛,避免新建与其并行的权限工具层。
|
||||
|
||||
### 未引用 Go 私有自由函数候选
|
||||
### 第五批已核实并删除的 Go 私有自由函数
|
||||
|
||||
扫描发现 16 个,第一批暂未改动后端。后续逐包确认调用、构建入口和测试后处理:
|
||||
初次扫描的 16 个候选和 2 个仅被候选调用的辅助函数已清理:
|
||||
|
||||
| 文件(相对 `internal`) | 函数 |
|
||||
| --- | --- |
|
||||
| `application/repository/retriever/weaviate/repository.go` | `tokenizeQuery` |
|
||||
| `im/wechat/crypto.go` | `encryptAES128ECB` |
|
||||
| `application/repository/retriever/milvus/filter.go` | `formatValue` |
|
||||
| `application/repository/retriever/milvus/filter.go` | `formatValue`、`escapeDoubleQuotes` |
|
||||
| `agent/tools/todo_write.go` | `getStringArrayField`、`getStringField` |
|
||||
| `agent/prompts.go` | `formatFileSize` |
|
||||
| `utils/inject.go` | `extractTableAliasMap` |
|
||||
| `utils/inject.go` | `extractTableAliasMap`、`collectTableAliases` |
|
||||
| `application/service/wiki_ingest_dedup.go` | `countEntityConceptPages` |
|
||||
| `agent/tools/sandbox_ls.go` | `relativeSkillFileFromImagePath` |
|
||||
| `infrastructure/docparser/json_converter.go` | `indentJSON` |
|
||||
@@ -140,4 +151,4 @@
|
||||
- 构建仍报告大 chunk 提示;本轮未以 bundle 体积为目标,也没有测量构建前后的体积差异。
|
||||
- 未修改后端,未运行后端测试;未做浏览器视觉验收。
|
||||
|
||||
下一批优先逐包核实并清理未引用 Go 私有函数候选,再处理后端具体重复逻辑。涉及聊天、任务重试、权限或共享状态的改动应单独成批,以可验证的行为等价为边界。
|
||||
下一批优先处理 IMA、语雀等外部来源的重复文件名清理逻辑,核实调用与差异后再决定共享边界。涉及聊天、任务重试、权限或共享状态的改动应单独成批,以可验证的行为等价为边界。
|
||||
|
||||
@@ -11,24 +11,6 @@ import (
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
)
|
||||
|
||||
// formatFileSize formats file size in human-readable format
|
||||
func formatFileSize(size int64) string {
|
||||
const (
|
||||
KB = 1024
|
||||
MB = 1024 * KB
|
||||
GB = 1024 * MB
|
||||
)
|
||||
|
||||
if size < KB {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
} else if size < MB {
|
||||
return fmt.Sprintf("%.2f KB", float64(size)/KB)
|
||||
} else if size < GB {
|
||||
return fmt.Sprintf("%.2f MB", float64(size)/MB)
|
||||
}
|
||||
return fmt.Sprintf("%.2f GB", float64(size)/GB)
|
||||
}
|
||||
|
||||
// formatDocSummary cleans and truncates document summaries for table display
|
||||
func formatDocSummary(summary string, maxLen int) string {
|
||||
cleaned := strings.TrimSpace(summary)
|
||||
|
||||
@@ -281,18 +281,6 @@ func inspectablePathError(requested string) string {
|
||||
return base + ". Use read_file with a listed skill:// resource for skill packages."
|
||||
}
|
||||
|
||||
func relativeSkillFileFromImagePath(clean, skillName string) string {
|
||||
dir, err := sandbox.SkillDirFor(skillName)
|
||||
if err != nil || clean == dir {
|
||||
return ""
|
||||
}
|
||||
prefix := dir + "/"
|
||||
if strings.HasPrefix(clean, prefix) {
|
||||
return strings.TrimPrefix(clean, prefix)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// matchingInspectableRoot returns the allowlisted root that contains
|
||||
// clean, or ("", false) when the path sits outside every root.
|
||||
func matchingInspectableRoot(clean string) (string, bool) {
|
||||
|
||||
@@ -199,32 +199,6 @@ func (t *TodoWriteTool) Execute(ctx context.Context, args json.RawMessage) (*typ
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Helper function to safely get string field from map
|
||||
func getStringField(m map[string]interface{}, key string) string {
|
||||
if val, ok := m[key].(string); ok {
|
||||
return val
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Helper function to safely get string array field from map
|
||||
func getStringArrayField(m map[string]interface{}, key string) []string {
|
||||
if val, ok := m[key].([]interface{}); ok {
|
||||
result := make([]string, 0, len(val))
|
||||
for _, item := range val {
|
||||
if str, ok := item.(string); ok {
|
||||
result = append(result, str)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
// Handle legacy string format for backward compatibility
|
||||
if val, ok := m[key].(string); ok && val != "" {
|
||||
return []string{val}
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// generatePlanOutput generates a formatted plan output
|
||||
func generatePlanOutput(task string, steps []PlanStep) string {
|
||||
output := "Plan created\n\n"
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"maps"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -204,33 +203,6 @@ func (c *filter) convertBetweenCondition(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func formatValue(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return fmt.Sprintf("\"%s\"", escapeDoubleQuotes(v))
|
||||
case int, int8, int16, int32, int64:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case uint, uint8, uint16, uint32, uint64:
|
||||
return fmt.Sprintf("%d", v)
|
||||
case float32, float64:
|
||||
return fmt.Sprintf("%v", v)
|
||||
case bool:
|
||||
if v {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case time.Time:
|
||||
return fmt.Sprintf("%d", v.Unix())
|
||||
default:
|
||||
return fmt.Sprintf("\"%v\"", value)
|
||||
}
|
||||
}
|
||||
|
||||
// escapeDoubleQuotes escapes double quotes in a string for use in Milvus expressions.
|
||||
func escapeDoubleQuotes(s string) string {
|
||||
return strings.ReplaceAll(s, "\"", "\\\"")
|
||||
}
|
||||
|
||||
// convertParamName converts field name to a valid Milvus template parameter name.
|
||||
// Milvus template parameters don't support '.' character, so we replace it with '_'.
|
||||
func (c *filter) convertParamName(field string, counter *int) string {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Tencent/WeKnora/internal/logger"
|
||||
"github.com/Tencent/WeKnora/internal/types"
|
||||
@@ -1035,30 +1034,3 @@ func fromWeaviateVectorEmbedding(id string,
|
||||
MatchType: matchType,
|
||||
}
|
||||
}
|
||||
|
||||
// tokenizeQuery splits a query string into tokens for OR-based full-text search.
|
||||
// It uses jieba for professional Chinese word segmentation.
|
||||
func tokenizeQuery(query string) []string {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use jieba for segmentation (search mode for better recall)
|
||||
words := types.Jieba.CutForSearch(query, true)
|
||||
|
||||
// Filter and deduplicate
|
||||
seen := make(map[string]bool)
|
||||
result := make([]string, 0, len(words))
|
||||
for _, word := range words {
|
||||
word = strings.TrimSpace(strings.ToLower(word))
|
||||
// Skip empty, single-char, and already seen words
|
||||
if utf8.RuneCountInString(word) < 2 || seen[word] {
|
||||
continue
|
||||
}
|
||||
seen[word] = true
|
||||
result = append(result, word)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -250,14 +250,8 @@ func markKnowledgeProcessing(knowledge *types.Knowledge, now time.Time) {
|
||||
knowledge.UpdatedAt = now
|
||||
}
|
||||
|
||||
// buildSplitterConfig creates a SplitterConfig with fallbacks from a KnowledgeBase.
|
||||
// Defaults mirror chunker.DefaultChunkSize / DefaultChunkOverlap so behavior is
|
||||
// identical whether callers come through this path or invoke the chunker
|
||||
// directly with a zero-value config.
|
||||
func buildSplitterConfig(kb *types.KnowledgeBase) chunker.SplitterConfig {
|
||||
return buildSplitterConfigFromChunking(kb.ChunkingConfig)
|
||||
}
|
||||
|
||||
// buildSplitterConfigFromChunking normalizes effective chunking settings with
|
||||
// the shared chunker defaults.
|
||||
func buildSplitterConfigFromChunking(cc types.ChunkingConfig) chunker.SplitterConfig {
|
||||
return chunker.NormalizeSplitterConfig(chunker.SplitterConfig{
|
||||
ChunkSize: cc.ChunkSize,
|
||||
|
||||
@@ -853,11 +853,6 @@ func getSkillURL(
|
||||
return body, resp.Header.Get("Content-Type"), nil
|
||||
}
|
||||
|
||||
func normalizeFetchedSkillArchive(body []byte, contentType, subdir string) ([]byte, error) {
|
||||
_, archive, err := normalizeFetchedSkill(body, contentType, subdir)
|
||||
return archive, err
|
||||
}
|
||||
|
||||
func normalizeFetchedSkill(body []byte, contentType, subdir string) (*SkillBundle, []byte, error) {
|
||||
if looksLikeSkillMarkdown(body) {
|
||||
files := map[string][]byte{"SKILL.md": body}
|
||||
|
||||
@@ -67,22 +67,6 @@ type dedupSurface struct {
|
||||
nameGramSets []map[string]struct{}
|
||||
}
|
||||
|
||||
// countEntityConceptPages returns how many of the given pages are
|
||||
// entity- or concept-typed. Used only for logging the prefilter's
|
||||
// reduction ratio.
|
||||
func countEntityConceptPages(pages []*types.WikiPage) int {
|
||||
n := 0
|
||||
for _, p := range pages {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
if p.PageType == types.WikiPageTypeEntity || p.PageType == types.WikiPageTypeConcept {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// selectDedupCandidatePages returns the subset of allPages plausibly
|
||||
// related to at least one of newItems. Non-entity/concept pages are
|
||||
// dropped unconditionally. The returned slice preserves the input order
|
||||
|
||||
@@ -1512,12 +1512,6 @@ func (h *KnowledgeHandler) DownloadKnowledgeFile(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// mimeTypeByExt returns the MIME type for a given file extension.
|
||||
func mimeTypeByExt(filename string) string {
|
||||
ct, _ := secutils.SafeContentTypeByFilename(filename)
|
||||
return ct
|
||||
}
|
||||
|
||||
// PreviewKnowledgeFile godoc
|
||||
// @Summary 预览知识文件
|
||||
// @Description 返回知识条目关联的原始文件,Content-Type 根据文件类型设置,用于浏览器内嵌预览
|
||||
|
||||
@@ -440,11 +440,6 @@ func (h *Handler) writeAgentQueryEvent(
|
||||
}
|
||||
}
|
||||
|
||||
// getRequestID gets the request ID from gin context
|
||||
func getRequestID(c *gin.Context) string {
|
||||
return c.GetString(types.RequestIDContextKey.String())
|
||||
}
|
||||
|
||||
// Helper function for type assertion with default value
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if val, ok := m[key].(string); ok {
|
||||
|
||||
@@ -193,10 +193,6 @@ func cleanIMContent(ctx context.Context, content string, tenant *types.Tenant, d
|
||||
return content
|
||||
}
|
||||
|
||||
func imLocalStorageBaseDir() string {
|
||||
return storageurl.LocalStorageBaseDir()
|
||||
}
|
||||
|
||||
// newIMFileServiceResolver builds a per-message storage backend resolver. The
|
||||
// cache lives for one cleanIMContent / outbound message so a long answer does
|
||||
// not re-create an SDK client for every reference.
|
||||
|
||||
@@ -44,29 +44,3 @@ func decryptAES128ECB(ciphertext, key []byte) ([]byte, error) {
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// encryptAES128ECB encrypts data with AES-128-ECB and PKCS#7 padding.
|
||||
// Used for uploading media files to the iLink CDN.
|
||||
func encryptAES128ECB(plaintext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new aes cipher: %w", err)
|
||||
}
|
||||
|
||||
bs := block.BlockSize()
|
||||
|
||||
// PKCS#7 padding
|
||||
padLen := bs - len(plaintext)%bs
|
||||
padded := make([]byte, len(plaintext)+padLen)
|
||||
copy(padded, plaintext)
|
||||
for i := len(plaintext); i < len(padded); i++ {
|
||||
padded[i] = byte(padLen)
|
||||
}
|
||||
|
||||
ciphertext := make([]byte, len(padded))
|
||||
for i := 0; i < len(padded); i += bs {
|
||||
block.Encrypt(ciphertext[i:i+bs], padded[i:i+bs])
|
||||
}
|
||||
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
@@ -74,10 +74,9 @@ type SplitterConfig struct {
|
||||
}
|
||||
|
||||
// Default chunk sizing constants. Single source of truth for the entire
|
||||
// chunker package and (via knowledge.go::buildSplitterConfig) the
|
||||
// knowledge service. The frontend KnowledgeBaseEditorModal mirrors these
|
||||
// numbers in its initial form state — keep them in sync if you change
|
||||
// either value here.
|
||||
// chunker package and the knowledge service via NormalizeSplitterConfig.
|
||||
// The frontend KnowledgeBaseEditorModal mirrors these numbers in its initial
|
||||
// form state — keep them in sync if you change either value here.
|
||||
//
|
||||
// DefaultChunkSize = 512 chars: ~100–130 English tokens / ~300 Chinese
|
||||
// tokens. Validated as a strong baseline by the Vecta Feb-2026 benchmark
|
||||
|
||||
@@ -314,8 +314,8 @@ func runTier(tier StrategyTier, text string, cfg SplitterConfig, profile *DocPro
|
||||
}
|
||||
|
||||
// ensureDefaults fills in zero-value config fields with sane defaults.
|
||||
// Mirrors buildSplitterConfig in internal/application/service/knowledge.go
|
||||
// so direct callers of this package get the same numbers.
|
||||
// NormalizeSplitterConfig also uses these defaults so service callers and
|
||||
// direct callers of this package get the same numbers.
|
||||
//
|
||||
// When cfg.TokenLimit is set, ChunkSize is clamped to the character budget
|
||||
// that fits within that token limit (with a 10% safety factor). This makes
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package docparser
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
@@ -205,15 +204,6 @@ func formatValue(v interface{}) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// indentJSON formats raw JSON bytes with indentation.
|
||||
func indentJSON(data []byte) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.Indent(&buf, data, "", " "); err != nil {
|
||||
return string(data), err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// wrapCodeBlock wraps content in a fenced JSON code block.
|
||||
func wrapCodeBlock(content string) string {
|
||||
return "```json\n" + content + "\n```"
|
||||
|
||||
@@ -154,13 +154,8 @@ func (p *SandboxGatewayTransportPool) dataTransport(target string) http.RoundTri
|
||||
return actual.(http.RoundTripper)
|
||||
}
|
||||
|
||||
// newGatewayDataTransport dials target regardless of the request's authority,
|
||||
// mirroring the SDK's proxy rewrite while adding the outbound address guard
|
||||
// the SDKs have no notion of.
|
||||
func newGatewayDataTransport(target string) *http.Transport {
|
||||
return newGatewayDataTransportWithPolicy(target, DefaultOutboundURLPolicy())
|
||||
}
|
||||
|
||||
// newGatewayDataTransportWithPolicy dials target regardless of the request's
|
||||
// authority, preserving the gateway rewrite and the configured outbound guard.
|
||||
func newGatewayDataTransportWithPolicy(target string, policy OutboundURLPolicy) *http.Transport {
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
|
||||
@@ -976,45 +976,6 @@ func ValidateAndSecureSQL(sql string, opts ...SQLValidationOption) (string, *SQL
|
||||
return securedSQL, validationResult, nil
|
||||
}
|
||||
|
||||
// extractTableAliasMap walks the parse tree to build a table_name→alias map.
|
||||
// When a table has an alias (e.g., "knowledge_bases kb"), the map entry is
|
||||
// {"knowledge_bases": "kb"}. Without an alias, both key and value are the table name.
|
||||
func extractTableAliasMap(parseResult *pg_query.ParseResult) map[string]string {
|
||||
m := make(map[string]string)
|
||||
if len(parseResult.Stmts) == 0 || parseResult.Stmts[0].Stmt == nil {
|
||||
return m
|
||||
}
|
||||
selectStmt := parseResult.Stmts[0].Stmt.GetSelectStmt()
|
||||
if selectStmt == nil {
|
||||
return m
|
||||
}
|
||||
for _, fromItem := range selectStmt.FromClause {
|
||||
collectTableAliases(fromItem, m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// collectTableAliases recursively collects table→alias mappings from FROM clause nodes.
|
||||
func collectTableAliases(node *pg_query.Node, m map[string]string) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if rv := node.GetRangeVar(); rv != nil {
|
||||
tableName := strings.ToLower(rv.Relname)
|
||||
alias := tableName
|
||||
if rv.Alias != nil && rv.Alias.Aliasname != "" {
|
||||
alias = strings.ToLower(rv.Alias.Aliasname)
|
||||
}
|
||||
m[tableName] = alias
|
||||
return
|
||||
}
|
||||
if je := node.GetJoinExpr(); je != nil {
|
||||
collectTableAliases(je.Larg, m)
|
||||
collectTableAliases(je.Rarg, m)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// walkSQLMessages visits children before their parent so rewriting a SELECT
|
||||
// does not revisit trusted predicates added by the server.
|
||||
func walkSQLMessages(message protoreflect.Message, visit func(protoreflect.Message) error) error {
|
||||
|
||||
@@ -192,7 +192,7 @@ var splitByHeuristics = func(text string, cfg SplitterConfig, _ *DocProfile) []C
|
||||
- 增加策略常量(如 `StrategyMine = "mine"`)与新的 `StrategyTier`;
|
||||
- 在 `resolveChain`/`resolveChainWithProfile` 的 switch 中为新策略返回 tier 链(建议以 `TierLegacy` 兜底);
|
||||
- 在 `runTier()` 中新增 case;
|
||||
3. 调用方无需改动:知识库的 `chunking_config.strategy`(JSONB)经 `internal/application/service/knowledge.go` 的 `buildSplitterConfig` 传入;
|
||||
3. 调用方无需改动:知识库的 `chunking_config.strategy`(JSONB)经 `internal/application/service/knowledge_process.go` 的 `buildSplitterConfigFromChunking` 传入;
|
||||
4. 用 `SplitWithDiagnostics` 写单测验证 tier 选择与 `ValidateChunks` 验收行为。
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user