Posted in

Excel公式在Go中动态生成失败?——AST解析器构建、依赖图拓扑排序、循环引用检测全链路实现

第一章:Excel公式动态生成的核心挑战与Go语言适配性分析

Excel公式的动态生成并非简单拼接字符串,而需兼顾语义正确性、上下文感知、引用稳定性及跨平台兼容性。典型挑战包括:单元格引用的相对/绝对/混合模式自动推导、命名区域与表格结构(如 Table1[@Column])的语法解析、数组公式与动态数组函数(如 SEQUENCEFILTER)的嵌套合法性校验,以及多工作表、外部工作簿链接等跨作用域场景下的公式依赖追踪。

Go语言在该领域展现出独特优势:其强类型系统与编译期检查可提前捕获非法引用;strings.Buildertext/template 提供高效、安全的公式字符串构建能力;丰富的第三方库(如 tealeg/xlsxgoxlsx)支持读写 .xlsx 文件并提取工作表结构元数据。更重要的是,Go 的并发模型天然适配批量公式生成任务——例如为千行数据自动生成带条件格式的 IF 公式链:

// 示例:为A2:A1001生成动态IF公式,判断B列是否为空
func generateIFFormulas(startRow, endRow int) []string {
    formulas := make([]string, 0, endRow-startRow+1)
    for i := startRow; i <= endRow; i++ {
        // 构建绝对列B、相对行i的引用:B[i]
        formula := fmt.Sprintf(`=IF(ISBLANK($B$%d),"","OK")`, i)
        formulas = append(formulas, formula)
    }
    return formulas
}
// 执行逻辑:生成1000个独立公式,无共享状态,可并行分片处理

关键适配点对比:

维度 Excel原生VBA局限 Go语言解决方案
公式语法校验 运行时报错,无静态检查 利用AST解析器预验证公式结构合法性
大规模生成性能 单线程,内存占用高 goroutine池并发写入,内存复用
跨平台部署 依赖Windows COM组件 编译为Linux/macOS/Windows二进制
模板化能力 有限的字符串替换 支持嵌套模板与自定义函数(如日期格式化)

此外,Go的 encoding/json 可将业务规则(如“当销售额>10000时标红”)序列化为结构化配置,驱动公式生成引擎,实现逻辑与表达分离。

第二章:AST解析器构建:从Excel公式文本到Go语法树的全链路实现

2.1 Excel公式的词法分析与Token定义规范

Excel公式解析的第一步是将原始字符串切分为具有语义的原子单元——Token。这要求明确定义识别规则与边界条件。

常见Token类型及语义

Token类别 示例 说明
IDENTIFIER SUM, A1, MyRange 函数名、单元格引用、命名区域
NUMBER 3.14, -42 十进制数字(支持科学计数法)
STRING "Hello" 双引号包裹的UTF-8文本
OPERATOR +, *, >=, & 算术、比较、连接运算符
DELIMITER (, ), ,, : 函数调用与范围界定符号

Token化核心逻辑(Python伪代码)

def tokenize(formula: str) -> List[Token]:
    i, tokens = 0, []
    while i < len(formula):
        char = formula[i]
        if char.isdigit() or char == '-':  # 启动数字扫描
            j = i
            while j < len(formula) and (formula[j].isdigit() or formula[j] in '.Ee+-'):
                j += 1
            tokens.append(Token("NUMBER", formula[i:j]))
            i = j
        elif char.isalpha() or char == '_':  # 标识符(含函数/引用)
            j = i
            while j < len(formula) and (formula[j].isalnum() or formula[j] in '_'):
                j += 1
            tokens.append(Token("IDENTIFIER", formula[i:j]))
            i = j
        # 其他分支略...
    return tokens

该实现采用贪心扫描策略,按优先级顺序匹配最长可能Token;i为全局游标,确保无重叠或遗漏。数字解析需兼容负号与指数符号,标识符则严格遵循Excel命名规范(首字符非数字)。

graph TD
    A[输入公式字符串] --> B{首字符类型?}
    B -->|字母/下划线| C[提取IDENTIFIER]
    B -->|数字/负号| D[提取NUMBER]
    B -->|双引号| E[提取STRING]
    B -->|运算符| F[映射OPERATOR]
    C --> G[输出Token流]
    D --> G
    E --> G
    F --> G

2.2 基于递归下降的语法分析器设计与Go泛型实践

递归下降解析器天然契合上下文无关文法的结构,而Go 1.18+泛型为节点类型安全与复用提供了新范式。

核心抽象:泛型解析器骨架

type Parser[T any] struct {
    tokens []Token
    pos    int
}

func (p *Parser[T]) Parse() (T, error) {
    var zero T
    // 实际解析逻辑由具体语法驱动,此处返回零值占位
    return zero, nil
}

T 表示语法树节点类型(如 *Expr*Stmt),tokens 为词法单元序列,pos 指向当前扫描位置;泛型约束确保类型安全,避免运行时断言。

关键优势对比

特性 传统接口实现 泛型实现
类型安全 ✅(需断言) ✅(编译期校验)
内存开销 ⚠️ 接口装箱 ✅ 零分配(栈内)
可读性 ❌ 类型模糊 ✅ 显式类型参数

解析流程示意

graph TD
    A[Start] --> B[Consume Token]
    B --> C{Match Rule?}
    C -->|Yes| D[Call Sub-parser]
    C -->|No| E[Error Recovery]
    D --> F[Build Typed Node]
    F --> G[Return T]

2.3 AST节点建模:支持嵌套函数、引用、数组公式的结构化表达

为精准表达复杂公式语义,AST需扩展三类核心节点:FunctionCallNode(含children: Node[]支持递归嵌套)、CellRefNode(携带sheet, row, col, isAbsolute元数据)与ArrayFormulaNode(封装range, expressionRoot及广播维度标记)。

节点类型设计要点

  • 嵌套函数:SUM(A1, IF(B1>0, MAX(C1:C10), 0)) → 深度优先构建子树
  • 引用解析:$A$1:B2isAbsolute按方向独立标记(col: true, row: false
  • 数组公式:=SEQUENCE(3,2)*{1,2;3,4}ArrayFormulaNode包裹标量运算符与矩阵常量

示例:嵌套函数AST片段

interface FunctionCallNode extends BaseNode {
  name: string;              // 函数名,如 "IF"
  args: Node[];              // 参数列表,可含任意Node子类
  isArrayContext: boolean;   // 标记是否在数组公式作用域内求值
}

args字段允许无限递归嵌套——IF的第三个参数可为SUM节点,SUM的参数又可为CellRefNode,形成树状拓扑;isArrayContext保障广播规则正确应用。

节点类型 关键字段 语义作用
CellRefNode sheet, row, col 定位跨表/动态引用
ArrayFormulaNode broadcastDim: 'row'\|'col'\|'both' 控制隐式扩展方向
graph TD
  A[Root Formula] --> B[FunctionCallNode SUM]
  B --> C[CellRefNode A1]
  B --> D[FunctionCallNode IF]
  D --> E[BinaryOpNode >]
  D --> F[FunctionCallNode MAX]
  F --> G[RangeNode C1:C10]

2.4 公式上下文绑定:单元格地址、工作表作用域与命名区域的语义注入

公式并非孤立表达式,其语义高度依赖运行时上下文。Excel 中 A1 在不同位置解析为不同物理地址——这是相对引用的本质

单元格地址的动态解析

=SUM(A1:A10)  // 在 Sheet1!B5 中执行 → 实际求和范围为 Sheet1!B1:B10

逻辑分析:A1:A10 是相对地址,Excel 将列偏移(-1)与行偏移(-4)自动施加于当前单元格(B5),生成真实求值区域。参数 A1 的列索引(1)与当前列(2)差值决定横向位移。

工作表作用域隔离

引用形式 作用域 是否跨表可见
C3 当前表
Sheet2!D7 显式指定表
'Data Summary'!E2 支持空格表名

命名区域的语义注入

graph TD
    A[公式输入] --> B{解析命名区域}
    B -->|存在定义| C[替换为绝对地址或数组]
    B -->|未定义| D[报错 #NAME?]
    C --> E[注入上下文元数据:所属表、重计算标记]

命名区域(如 SalesQ1)将业务语义(“本季度销售额”)直接注入公式引擎,使 =SUM(SalesQ1) 具备跨工作表一致性与维护可读性。

2.5 错误恢复机制:语法错误定位、建议修复与用户友好提示

精准定位语法错误位置

现代解析器采用“同步集恢复”策略,在遇到非法 token 时跳过至最近的合法恢复点(如 ;} 或关键字),并记录偏移量与上下文行号。

智能修复建议生成

基于 AST 差分与常见模式库,为典型错误提供上下文感知建议:

# 示例:缺失右括号的修复建议
def parse_expression(tokens):
    stack = []
    for i, t in enumerate(tokens):
        if t in ['(', '[', '{']:
            stack.append((t, i))
        elif t in [')', ']', '}']:
            if not stack or not _matches(stack[-1][0], t):
                # 返回修复建议:插入缺失符号的位置与类型
                return {"pos": i, "suggestion": f"insert {opposite(t)} before token {i}"}
            stack.pop()

逻辑分析:stack 记录开符号及其索引;_matches() 判断括号匹配性;opposite() 映射闭合符号。参数 i 提供精确插入位置,suggestion 字符串直接用于 UI 渲染。

用户友好提示设计原则

维度 传统提示 改进方案
可读性 SyntaxError: invalid syntax Expected ')' to close '(' at line 12, column 27
操作性 无操作指引 内联“🔧 Apply fix”按钮 + 预览差异
graph TD
    A[输入源码] --> B{词法/语法分析}
    B -->|错误触发| C[定位错误 token 及上下文窗口]
    C --> D[生成候选修复方案]
    D --> E[按置信度排序并过滤 UX 不友好项]
    E --> F[渲染带高亮+操作按钮的提示]

第三章:依赖图建模与拓扑排序:确保公式求值顺序的数学基础与工程落地

3.1 单元格依赖关系的形式化建模与有向图构建

在电子表格引擎中,单元格依赖关系本质是计算顺序约束。每个公式单元格 $C_i$ 显式引用一组源单元格 $\text{refs}(C_i) = {C_j, C_k, \dots}$,构成有向边 $C_j \to C_i$。

依赖关系的数学定义

设工作表状态为有向图 $G = (V, E)$:

  • 顶点集 $V = {C_1, C_2, \dots, C_n}$ 表示所有非空单元格;
  • 边集 $E = {(u, v) \mid u \in \text{refs}(v)}$ 表示“被引用→引用”方向。

构建逻辑示例(Python伪代码)

def build_dependency_graph(sheet):
    graph = defaultdict(set)
    for cell in sheet.non_empty_cells():
        if cell.has_formula():
            for ref in cell.parse_references():  # 如 "A1", "Sheet2!B5"
                graph[ref].add(cell.address)  # ref → cell,体现数据流向
    return graph

parse_references() 提取相对/绝对引用并标准化地址;graph[ref].add(...) 确保拓扑排序时先更新被依赖项。

单元格 公式 直接依赖
B2 =A1+A2 A1, A2
C2 =B2*10 B2
D1 =SUM(C:C) C1,C2,…
graph TD
    A1 --> B2
    A2 --> B2
    B2 --> C2
    C1 --> D1
    C2 --> D1

3.2 基于Kahn算法的增量式拓扑排序与并发安全实现

传统Kahn算法需全量重排,而动态图场景要求高效响应边增删。核心挑战在于:如何在不重建入度数组与队列的前提下,局部更新拓扑序并保证多线程访问一致性。

并发安全的数据结构设计

  • 使用 AtomicIntegerArray 管理节点入度,支持无锁递减/递增
  • 拓扑队列采用 ConcurrentLinkedQueue,避免 PriorityBlockingQueue 的全局锁开销
  • 节点状态通过 volatile boolean[] processed 标记,确保可见性
// 原子化入度更新(插入新边 u→v)
int prev = inDegree.getAndIncrement(v); // 原子+1,返回旧值
if (prev == 0) { // v首次获得入边,但尚未就绪;仅当prev==0时才可能触发初始入队
    queue.offer(v); // 非阻塞入队
}

逻辑说明:getAndIncrement() 保证入度更新原子性;仅当原入度为0时才尝试入队(避免重复),配合后续decrementAndGet()校验实现精确触发。

增量更新流程(mermaid)

graph TD
    A[接收边 e=u→v] --> B{v入度是否为0?}
    B -->|是| C[将v加入并发队列]
    B -->|否| D[仅原子增入度]
    C --> E[worker线程消费v,遍历其邻接点w]
    E --> F[对每个w:inDegree[w].decrementAndGet()==0?]
    F -->|是| C
特性 全量Kahn 本方案
时间复杂度 O(V+E) 摊还 O(ΔE)
线程安全 是(无锁+volatile)
内存复用 是(复用入度数组与队列)

3.3 动态依赖更新:公式修改后依赖图的局部重建与缓存失效策略

当用户编辑某单元格公式(如 =A1+B2=A1*C3),系统需精准识别影响范围,避免全图重建。

局部重建触发逻辑

仅重计算直接受影响节点及其下游可达节点,跳过无关分支。依赖图采用邻接表存储,支持 O(1) 边删除与 O(d⁺) 增量遍历(d⁺为出度)。

缓存失效粒度控制

失效类型 触发条件 影响范围
节点级 公式文本变更 当前节点 + 所有下游缓存
边级 操作符/引用变更(如 +*B2C3 仅该依赖边对应输出缓存
def invalidate_and_rebuild(node: Node, new_formula: str):
    old_deps = node.dependencies  # 原依赖集合:{A1, B2}
    new_deps = parse_references(new_formula)  # 新依赖:{A1, C3}
    removed = old_deps - new_deps  # {B2} → 触发上游缓存校验
    added = new_deps - old_deps    # {C3} → 需拉取新值并验证有效性
    node.formula = new_formula
    node.dependencies = new_deps
    propagate_recalc(node)  # 仅向下游拓扑排序执行

上述逻辑确保 removed 中变量若被其他节点复用,其缓存仍保留;added 引用缺失时触发按需加载。

依赖变更传播流程

graph TD
    A[公式修改] --> B{解析新依赖集}
    B --> C[计算增删引用差集]
    C --> D[标记失效边与节点]
    D --> E[拓扑排序重计算]
    E --> F[增量更新缓存哈希]

第四章:循环引用检测与诊断:从图论判定到可调试交互式报告生成

4.1 强连通分量(SCC)识别:Tarjan算法在Go中的高效实现与内存优化

Tarjan算法通过一次DFS遍历识别有向图中所有强连通分量,核心在于维护disc(发现时间)、low(可回溯最早节点)及栈式路径记录。

核心数据结构设计

  • 使用切片模拟栈,避免container/list的接口开销
  • low[]disc[]共用同一[]int底层数组,节省50%内存
  • 节点状态用bool数组标记是否在栈中,而非map查找

关键代码实现

func tarjan(g [][]int) [][]int {
    n := len(g)
    disc, low := make([]int, n), make([]int, n)
    onStack := make([]bool, n)
    stack := make([]int, 0, n)
    sccs := make([][]int, 0)
    time := 0

    var dfs func(int)
    dfs = func(u int) {
        disc[u], low[u] = time, time
        time++
        stack = append(stack, u)
        onStack[u] = true

        for _, v := range g[u] {
            if disc[v] == 0 { // 未访问
                dfs(v)
                low[u] = min(low[u], low[v])
            } else if onStack[v] { // 回边
                low[u] = min(low[u], disc[v])
            }
        }

        if low[u] == disc[u] { // 发现SCC根节点
            var scc []int
            for {
                w := stack[len(stack)-1]
                stack = stack[:len(stack)-1]
                onStack[w] = false
                scc = append(scc, w)
                if w == u {
                    break
                }
            }
            sccs = append(sccs, scc)
        }
    }

    for i := 0; i < n; i++ {
        if disc[i] == 0 {
            dfs(i)
        }
    }
    return sccs
}

逻辑说明

  • disc[u]记录DFS首次到达u的时间戳;low[u]表示u及其后代能通过后向边/横跨边到达的最小disc值;
  • low[u] == disc[u]时,u是当前SCC的根,栈中从u到栈顶即为该SCC全部节点;
  • onStack[]替代哈希查找,将入栈/出栈状态判断降至O(1);
  • 切片预分配容量n,避免动态扩容带来的内存碎片。
优化项 传统实现 本实现
状态存储 map[int]bool []bool
栈结构 container/list []int
时间复杂度 O(V+E) O(V+E)(常数更优)
graph TD
    A[开始DFS] --> B{节点u未访问?}
    B -->|是| C[设置disc[u]=low[u]=time++]
    B -->|否| D[跳过]
    C --> E[压入栈,标记onStack[u]=true]
    E --> F[遍历邻接点v]
    F --> G{v未访问?}
    G -->|是| H[递归DFS v,更新low[u]]
    G -->|否| I{v在栈中?}
    I -->|是| J[用disc[v]更新low[u]]
    I -->|否| K[忽略横跨边]
    H & J --> L{low[u] == disc[u]?}
    L -->|是| M[弹出栈至u,构成SCC]
    L -->|否| N[继续遍历]

4.2 循环路径可视化:生成可追溯的引用链JSON与DOT图输出

当检测到模块间循环依赖(如 A→B→C→A)时,需构建可回溯的引用链快照,支持调试与架构治理。

核心数据结构

引用链以嵌套对象形式记录调用上下文:

{
  "start": "module_a",
  "path": ["module_a", "module_b", "module_c", "module_a"],
  "cycle_length": 4,
  "trace_id": "trc-7f3a9b1e"
}

该结构确保每个循环路径具备唯一 trace_id,便于日志关联与分布式追踪对齐。

输出双格式能力

格式 用途 工具链集成
JSON API 响应、CI/CD 流水线消费 jq / Python json.loads()
DOT Graphviz 渲染为矢量图 dot -Tpng cycle.dot > cycle.png

可视化流程

graph TD
    A[解析AST依赖图] --> B[DFS检测环]
    B --> C[提取完整引用路径]
    C --> D[序列化为JSON+DOT]

4.3 用户级诊断辅助:自动定位源头单元格、高亮冲突路径与修复建议生成

当用户在复杂公式网络中遭遇 #VALUE!#REF! 错误时,系统启动三级诊断流水线:

冲突路径回溯引擎

基于有向无环图(DAG)构建依赖快照,逆向追踪至首个异常输入单元格。

def trace_error_source(cell_id: str) -> tuple[str, list[str]]:
    # cell_id: 目标错误单元格(如 "C10")
    # 返回:源头ID + 完整冲突路径(含中间计算节点)
    path = graph.reverse_bfs(cell_id, stop_cond=lambda n: is_input_cell(n) or has_raw_error(n))
    return path[-1], path

逻辑分析:reverse_bfs 以错误单元格为起点反向遍历依赖边;stop_cond 在遇到原始数据源(非公式)或含硬编码错误值时终止,确保精准捕获源头。

修复建议生成规则表

错误类型 检测特征 推荐操作
类型不匹配 TEXT() 输出参与 SUM() 插入 VALUE() 转换
引用越界 INDIRECT("Z999") 解析失败 替换为动态命名区域

可视化反馈流程

graph TD
    A[检测错误单元格] --> B[构建依赖子图]
    B --> C[高亮路径:红→橙→黄]
    C --> D[生成上下文感知建议]

4.4 性能边界控制:超大规模工作表下的采样检测与渐进式验证机制

当工作表行数突破百万量级,全量校验将引发内存溢出与响应延迟。为此,系统采用分层采样+增量验证双轨机制。

动态采样策略

依据数据稀疏度自动选择采样方式:

  • 密集区域(如连续数值列):分块随机采样(每10k行取50行)
  • 稀疏区域(如含空值的文本列):按非空行密度动态提升采样率

渐进式验证流程

def progressive_validate(chunk, schema, confidence=0.95):
    # chunk: pandas DataFrame,当前数据块
    # schema: 字段类型与约束定义
    # confidence: 置信度阈值,影响后续验证深度
    sample = chunk.sample(frac=min(0.02, 200/len(chunk)), random_state=42)
    errors = validate_sample(sample, schema)  # 基础类型与非空校验
    if len(errors) == 0 and len(chunk) > 10000:
        return deep_validate(chunk.iloc[:5000], schema)  # 触发轻量级深度校验
    return errors

该函数通过frac参数实现自适应采样率衰减,避免小表过度采样、大表采样不足;confidence隐式影响后续是否启用表达式级规则扫描。

验证阶段 覆盖范围 耗时占比 检出率(典型场景)
初筛采样 1–2% 行 78% 数值越界
区间扫描 连续5k行 ~15% 92% 格式一致性
全量回溯 仅异常块 按需触发 100% 业务逻辑约束

graph TD A[原始工作表] –> B{行数 > 500k?} B –>|是| C[启动分块采样] B –>|否| D[标准全量校验] C –> E[首块采样验证] E –> F{错误率 |是| G[跳过后续块,仅抽检边缘区] F –>|否| H[对相邻块执行区间扫描]

第五章:面向生产环境的Excel公式引擎演进路线图

核心挑战与现实约束

某大型保险集团在2023年上线的精算报表平台,初期采用Excel COM Automation调用原生Excel进程执行公式计算,导致单次批处理耗时超47分钟,且在并发12个任务时频繁触发Excel崩溃(错误码0x800A03EC)。根本原因在于COM线程模型与IIS工作进程冲突,且Excel进程无法被可靠回收。该案例直接推动团队放弃“宿主依赖”路径,转向可嵌入、可监控、可灰度的轻量级公式引擎架构。

公式解析层重构实践

团队将OpenFormula标准(OASIS TC 2021)作为语法基石,结合ANTLR v4构建分层解析器:词法分析器识别[@[保费]]*1.05等结构化引用,语法分析器生成AST,语义分析器注入上下文元数据(如单元格所属SheetID、版本时间戳)。关键改进是引入缓存感知表达式树——对SUMIFS(表1[金额],表1[状态],"已核保",表1[日期],">="&TODAY()-30)自动剥离常量子树并标记为@cacheable:true,实测使重复查询响应从820ms降至43ms。

执行引擎性能对比

引擎类型 并发吞吐(req/s) 内存占用(GB) 公式兼容性(Excel 365函数覆盖率) 热更新支持
Excel COM 1.2 3.8 100%
CalcEngine(Apache POI) 24.7 1.1 68%(缺失LAMBDA/REDUCE)
自研FormulaCore 189.3 0.4 92%(通过宏扩展支持LET/SEQUENCE)

运维可观测性集成

在Kubernetes集群中部署FormulaCore时,通过OpenTelemetry注入三类指标:formula_eval_duration_seconds_bucket(按函数类型分桶)、cell_dependency_graph_depth(追踪跨Sheet引用深度)、cache_hit_ratio(区分内存/Redis缓存命中率)。当某日发现XLOOKUP调用延迟突增300%,通过火焰图定位到外部API网关DNS解析超时,而非引擎本身问题。

graph LR
A[用户提交公式] --> B{语法校验}
B -->|通过| C[AST编译]
B -->|失败| D[返回SyntaxError+行号列号]
C --> E[依赖分析]
E --> F[加载数据源快照]
F --> G[执行沙箱环境]
G --> H[结果序列化JSON]
H --> I[写入ClickHouse审计日志]

安全边界控制机制

所有公式执行均运行于gVisor隔离容器内,禁用INDIRECTCELL等危险函数;对HYPERLINK参数强制URL白名单校验(正则^https?://(?:corp-internal\.domain|cdn\.example\.com)/.*$);当检测到REPT("A",1000000)类资源耗尽尝试时,立即触发OOM Killer并记录formula_abuse_event事件到SIEM系统。

灰度发布策略

新版本FormulaCore v2.3通过Service Mesh实现流量切分:10%请求走新引擎,90%走旧版;同时比对两套引擎输出的SHA256(result_json)哈希值,当差异率>0.001%时自动回滚。上线首周捕获3处数值精度差异——源于ROUNDUP在IEEE 754双精度下的舍入偏差,最终通过引入BigDecimal中间表示修复。

生产环境故障复盘

2024年Q2发生一次P0事故:某财务模块因TEXTJOIN函数在空数组输入时返回#VALUE!而非空字符串,导致下游ETL作业中断。根因是AST优化器错误折叠了FILTER(,TRUE)为空数组的边界条件。解决方案是增加单元测试矩阵覆盖{空数组,单元素,多元素} × {TRUE,FALSE,ERROR}共12种组合,并将该用例纳入CI准入门禁。

多模态数据源适配

FormulaCore v3.0新增对Delta Lake表的原生支持:当公式中出现=SUM('delta:/data/claims@2024-06-01'[赔付金额])时,引擎自动调用Delta Rust SDK读取指定版本快照,避免Spark SQL全表扫描。实测处理1.2亿行理赔数据时,较传统ODBC桥接方式降低IO等待时间68%。

用实验精神探索 Go 语言边界,分享压测与优化心得。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注