用Python和SQLAlchemy设计心理量表数据库:维度、计分与常模建模指南
简介面向心理学研究与教育场景的常用评估量表数据库源码包提供一套可扩展的量表数据管理方案帮助研究者与开发者高效检索、存储和维护百余种常用心理评估量表。整个压缩包共1043个文件体积约61.63MB主要包含Python脚本、rst文档、JSON数据文件、PDF手册、txt文本与docx文档脚本承担量表逻辑与数据处理数据文件保存结构化条目和量表描述文档则收录精神科评定量表手册、儿童行为量表等参考资料。项目目录结构清晰涵盖量表元数据、接口调用、说明文档和许可文件既可直接作为心理学测评工具开发的代码底座也可用于学习数据库表结构设计与量表规范化流程。目前已有364人浏览学习适合希望基于Python扩展自有量表库的研究者与开发者。1. 为什么心理量表库的数据模型不能照着问卷系统的表结构抄基于Python开发常用心理学评估量表的数据库最容易踩的坑不是Python语法而是把量表当成普通问卷来建表。问卷的第一诉求是收集答案量表的诉求是算出可靠的结果SCL-90要按躯体化、强迫、人际敏感等因子分组计分SDS有反向计分项结果还要对照常模换标准分。这套逻辑一旦落到表结构上题目表和答案表就只是最底层真正的核心是维度映射表、计分规则和常模版本。这篇笔记会把结构设计、SQLAlchemy建模、入库脚本、评分流程和五个典型坑一次性讲透。适合正在做心理测评小程序、量表科研管理系统或者想把常用量表做成题库中间件的人参考照着建表就能把第一版跑起来。2. 量表数据结构的核心维度、计分方向与常模的表设计动笔建表之前先回答一个根本问题量表的数据模型比普通问卷多出哪三样东西答案是维度归属、计分方向和常模解释。这三样东西缺一个量表库就退化成问卷库。2.1 量表与问卷的本质差别维度、计分方向与常模普通问卷的计分逻辑通常是“每道题等权累加”总分高就是某个倾向强。量表不是这样。SCL-90的90道题要先归到10个因子维度里每个因子单独算原始分SDS里有10道反向计分题选“5”实际要按“1”计算完原始分还不算完要拿去对照常模区间换成标准分和等级才能写进报告。这意味着维度、计分方向、常模这些概念不是评分函数里的临时变量而是数据模型的一部分。维度要能挂在量表下题目要能声明它属于哪个维度每个维度要能引用一份常模版本。如果把这些写成服务端硬编码每加一个量表就改一次代码改到第三个量表时你一定会想重构。设计点普通问卷心理量表题目归属直接属于问卷属于维度维度再归属量表计分规则通常等权累加有正向/反向、有权重结果解读看总分原始分再查常模换标准分与等级版本管理改题影响极小改版必须保留历史快照表结构选型上我一般把维度单独建表而不是在题目表上放一个dimension_code字符串字段。原因是维度本身有名称、描述、排序、常模引用它是一等实体用外键JOIN表达“题目属于哪个维度”比字符串编码可靠得多查询时也直观。数据库选型方面开发起步用SQLite多用户上线切PostgreSQL两边语法兼容避免一上来被MySQL的严格模式和无外键默认配置耗掉半天时间。2.2 五张核心表的 DDL从量表主表到维度题项映射我常用的起步方案是五张表scale量表主表、scale_dimension维度表、question题目表、question_option选项表、dim_question_mapping维度题项映射表。后面按需补norm常模表和scale_interpretation结果解释表。下面这份DDL可以直接在SQLite里执行PostgreSQL只需把AUTOINCREMENT换成IDENTITY。-- 量表主表一个量表一个版本一条记录 CREATE TABLE scale ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL UNIQUE, -- 量表编码如 scl90 / sds / sas name TEXT NOT NULL, -- 量表名称 version TEXT NOT NULL DEFAULT 1.0, -- 量表版本号配合施测快照用 instruction TEXT, -- 施测指导语 norm_source TEXT, -- 常模来源说明方便追溯 is_deleted INTEGER NOT NULL DEFAULT 0, -- 软删除标记 created_at TEXT NOT NULL DEFAULT (datetime(now)) ); -- 维度表一个量表有多个维度如 SCL-90 的躯体化、强迫等因子 CREATE TABLE scale_dimension ( id INTEGER PRIMARY KEY AUTOINCREMENT, scale_id INTEGER NOT NULL REFERENCES scale(id), code TEXT NOT NULL, -- 维度编码如 F1、F2 name TEXT NOT NULL, -- 维度名称 description TEXT, sort_no INTEGER NOT NULL DEFAULT 0, -- 展示顺序 UNIQUE(scale_id, code) ); -- 题目表只存题面不存计分方向 CREATE TABLE question ( id INTEGER PRIMARY KEY AUTOINCREMENT, scale_id INTEGER NOT NULL REFERENCES scale(id), code TEXT NOT NULL, -- 题号编码如 q01、q02 content TEXT NOT NULL, -- 题干文本 sort_no INTEGER NOT NULL DEFAULT 0, UNIQUE(scale_id, code) ); -- 选项表每个题目的选项值与文本option_value 用于计分 CREATE TABLE question_option ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL REFERENCES question(id), option_value INTEGER NOT NULL, -- 选项分值如 1~5 option_text TEXT NOT NULL, sort_no INTEGER NOT NULL DEFAULT 0, UNIQUE(question_id, option_value) ); -- 维度题项映射表核心中的核心计分方向放这里 CREATE TABLE dim_question_mapping ( id INTEGER PRIMARY KEY AUTOINCREMENT, dimension_id INTEGER NOT NULL REFERENCES scale_dimension(id), question_id INTEGER NOT NULL REFERENCES question(id), score_direction TEXT NOT NULL DEFAULT positive CHECK (score_direction IN (positive, negative)), -- 正向/反向计分 weight REAL NOT NULL DEFAULT 1.0, -- 加权系数默认 1 UNIQUE(dimension_id, question_id) ); -- 常模表原始分区间 - 标准分与等级 CREATE TABLE norm ( id INTEGER PRIMARY KEY AUTOINCREMENT, scale_id INTEGER NOT NULL REFERENCES scale(id), dimension_code TEXT NOT NULL, raw_lower REAL NOT NULL, raw_upper REAL NOT NULL, std_score REAL NOT NULL, level TEXT, -- 如 正常 / 轻度 / 中度 / 重度 norm_version TEXT NOT NULL DEFAULT default, UNIQUE(scale_id, dimension_code, norm_version, raw_lower, raw_upper) ); -- 结果解释表按维度与等级给出报告文案 CREATE TABLE scale_interpretation ( id INTEGER PRIMARY KEY AUTOINCREMENT, scale_id INTEGER NOT NULL REFERENCES scale(id), dimension_code TEXT NOT NULL, level TEXT NOT NULL, content TEXT NOT NULL, UNIQUE(scale_id, dimension_code, level) );这里有三个设计决策值得说明。第一score_direction放在映射表而不是question表因为“反向计分”描述的是这个维度如何给这道题计分语义上跟维度绑定万一以后出现同一道题被两个维度复用的场景映射表也能分别表达不同方向。第二weight字段先留好有些量表在维度合成时不是简单等权提前留这个字段避免以后改表结构。第三norm表的raw_lower和raw_upper是闭区间查询时一条WHERE就能定位不要用“lower AND upper”这种半开区间容易在边界值上漏人。常模表的数值我这里不写具体区间因为量表常模数据涉及版权与使用授权落地时以量表配套使用手册或授权来源为准。表结构上需要记住一点norm_version要与scale.version配合老答卷用老版本常模解释新答卷用新常模防止算出来的等级前后矛盾。3. 用 SQLAlchemy 建模五张表再写一份量表 JSON 导入脚本DDL 定完Python 侧要做两件事把表映射成 ORM 模型再给一份量表 JSON 导入脚本。顺序上先建模再导入因为导入脚本依赖模型。这里我选 SQLAlchemy版本用 2.x 的经典写法因为兼容性好网上 python 教程里也大多是这套写法读者接手成本低。如果你还在用裸 sqlite3 拼字符串 SQL每加一个量表就要改一次建表语句建议趁早换成 ORM。3.1 实体模型把 DDL 映射成 SQLAlchemy ORM模型文件我命名为 models.py五张表对应五个类。字段类型与第 2 章的 DDL 一一对应命名保持一致这样以后看日志或写原生 SQL 时不用来回翻译。from datetime import datetime from sqlalchemy import ( Column, Integer, String, Text, Float, ForeignKey, DateTime, UniqueConstraint, create_engine ) from sqlalchemy.orm import declarative_base, relationship Base declarative_base() class Scale(Base): __tablename__ scale id Column(Integer, primary_keyTrue, autoincrementTrue) code Column(String(32), nullableFalse, uniqueTrue) name Column(String(128), nullableFalse) version Column(String(16), nullableFalse, default1.0) instruction Column(Text) norm_source Column(String(255)) is_deleted Column(Integer, default0) created_at Column(DateTime, defaultdatetime.utcnow) dimensions relationship(ScaleDimension, back_populatesscale) questions relationship(Question, back_populatesscale) class ScaleDimension(Base): __tablename__ scale_dimension __table_args__ ( UniqueConstraint(scale_id, code, nameuq_scale_dimension_code), ) id Column(Integer, primary_keyTrue, autoincrementTrue) scale_id Column(Integer, ForeignKey(scale.id), nullableFalse) code Column(String(32), nullableFalse) name Column(String(128), nullableFalse) description Column(Text) sort_no Column(Integer, default0) scale relationship(Scale, back_populatesdimensions) mappings relationship(DimQuestionMapping, back_populatesdimension) class Question(Base): __tablename__ question __table_args__ ( UniqueConstraint(scale_id, code, nameuq_scale_question_code), ) id Column(Integer, primary_keyTrue, autoincrementTrue) scale_id Column(Integer, ForeignKey(scale.id), nullableFalse) code Column(String(32), nullableFalse) # 统一用 q01、q02 格式 content Column(Text, nullableFalse) sort_no Column(Integer, default0) scale relationship(Scale, back_populatesquestions) options relationship(QuestionOption, back_populatesquestion) mappings relationship(DimQuestionMapping, back_populatesquestion) class QuestionOption(Base): __tablename__ question_option __table_args__ ( UniqueConstraint(question_id, option_value, nameuq_question_option_value), ) id Column(Integer, primary_keyTrue, autoincrementTrue) question_id Column(Integer, ForeignKey(question.id), nullableFalse) option_value Column(Integer, nullableFalse) # 参与反向计分换算 option_text Column(String(255), nullableFalse) sort_no Column(Integer, default0) question relationship(Question, back_populatesoptions) class DimQuestionMapping(Base): __tablename__ dim_question_mapping __table_args__ ( UniqueConstraint(dimension_id, question_id, nameuq_dim_question), ) id Column(Integer, primary_keyTrue, autoincrementTrue) dimension_id Column(Integer, ForeignKey(scale_dimension.id), nullableFalse) question_id Column(Integer, ForeignKey(question.id), nullableFalse) score_direction Column(String(16), nullableFalse, defaultpositive) weight Column(Float, default1.0) dimension relationship(ScaleDimension, back_populatesmappings) question relationship(Question, back_populatesmappings)这段代码里有几个参数需要按你的环境调整。create_engine 的地址开发时用sqlite:///psy.db生产切 PostgreSQL 时改成postgresqlpsycopg2://user:passhost:5432/dbname模型代码一行不用动。created_at 用datetime.utcnow存 UTC 时间展示时再转本地时区这样测评数据跨时区时提交时间不会乱。is_deleted 做软删除但要小心如果题目被软删除UNIQUE(scale_id, code)仍然占着坑重新导入同名题目会撞唯一约束。我一般把被删记录的 code 改写成deleted_{code}_{id}或者干脆做物理删除因为题目一旦被施测引用硬删会破坏外键关系软删最稳。3.2 一份量表一个 JSON把题面、维度、计分规则一起入库ORM 模型建好之后下一步是让量表数据可以批量导入。常见做法是“一份量表一个 JSON 文件”用脚本解析拆表。下面这份示例采用 SCL-90 的题目风格但题干是我为演示写的示例真实量表数据以你拿到的授权源文件为准。{ code: demo_anxiety, name: 焦虑状态自评示例量表, version: 1.0, instruction: 请根据最近一周的情况作答, options_text: { 1: 没有或很少时间, 2: 小部分时间, 3: 相当多时间, 4: 绝大部分时间, 5: 几乎全部时间 }, dimensions: [ {code: F1, name: 焦虑, sort_no: 1} ], questions: [ { code: q01, content: 我感到心神不宁, sort_no: 1, options: [1, 2, 3, 4, 5], dimension: F1, score_direction: positive }, { code: q02, content: 我感到容易受到惊吓, sort_no: 2, options: [1, 2, 3, 4, 5], dimension: F1, score_direction: positive } ] }对应导入脚本如下它会读取 JSON拆出 Scale、ScaleDimension、Question、QuestionOption、DimQuestionMapping 五张表的数据并写入。import json from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from models import ( Base, Scale, ScaleDimension, Question, QuestionOption, DimQuestionMapping, ) def import_scale(session: Session, spec: dict) - None: # 量表主表 scale Scale( codespec[code], namespec[name], versionspec.get(version, 1.0), instructionspec.get(instruction), ) session.add(scale) session.flush() # 先落库拿到 scale.id # 维度表dim_map 缓存编码到对象的映射 dim_map {} for dim in spec.get(dimensions, []): record ScaleDimension( scale_idscale.id, codedim[code], namedim[name], sort_nodim.get(sort_no, 0), ) session.add(record) dim_map[dim[code]] record session.flush() # 题目、选项、维度映射 for item in spec[questions]: q Question( scale_idscale.id, codeitem[code], contentitem[content], sort_noitem.get(sort_no, 0), ) session.add(q) session.flush() for option_value in item.get(options, []): session.add(QuestionOption( question_idq.id, option_valueoption_value, option_textspec[options_text].get(str(option_value), ), sort_nooption_value, )) mapping DimQuestionMapping( dimension_iddim_map[item[dimension]].id, question_idq.id, score_directionitem.get(score_direction, positive), weightitem.get(weight, 1.0), ) session.add(mapping) if __name__ __main__: engine create_engine(sqlite:///psy.db) Base.metadata.create_all(engine) with sessionmaker(bindengine)() as session: with open(demo_anxiety.json, encodingutf-8) as f: import_scale(session, json.load(f)) session.commit()脚本里几处细节值得说明。第一session.flush() 不是 commit它只是把已添加的对象发送到数据库拿回自增主键如果省掉 flush后面引用 scale.id 时拿到的可能是 None。第二dim_map 用维度编码做键题目 JSON 里的 dimension 字段只要写 F1 这样的编码可读性比写数字 id 好得多也方便人工核对原始量表文档。第三这个脚本没有做幂等处理同一个 code 导入两次会触发唯一约束抛 IntegrityError实际使用中应在开头先按 code 查询一次已存在就直接 return 或提示更新。把量表定义做成 JSON 文件而不是直接写 INSERT最大的好处是“新增量表不写代码”。量表专家给你一份新的题目清单你只需要按这个 JSON 模板整理成文件跑一遍脚本即可。这也是后面第 6 章做 JSON Schema 校验的基础。4. 施测落库与评分流程从原始分到常模结果的完整实现量表库如果只有结构和导入脚本那只是个数据仓库。价值真正体现在“跑一次施测并算出标准分”这条流程上用户作答完成系统要能按维度聚合原始分再对照常模得出等级。这章分两层讲施测记录与答题明细的落库设计以及评分引擎的实现。4.1 施测记录与答题明细一次测评的两层数据一次施测的数据天然分两层头表 assessment 记录“谁在什么时间用哪个版本量表做了一次测评”状态会变化明细表 assessment_answer 记录每道题的答案只做追加。分开存的好处是查“这个人做过几次测评”只需要扫头表导出详细答题记录时再走明细表。class Assessment(Base): __tablename__ assessment id Column(Integer, primary_keyTrue, autoincrementTrue) user_id Column(Integer, nullableFalse, indexTrue) # 外部账号体系的用户ID scale_id Column(Integer, ForeignKey(scale.id), nullableFalse) scale_version Column(String(16), nullableFalse) # 施测时的量表版本快照 status Column(String(16), nullableFalse, defaultin_progress) # status 取值: in_progress / completed / abandoned started_at Column(DateTime, defaultdatetime.utcnow) submitted_at Column(DateTime) class AssessmentAnswer(Base): __tablename__ assessment_answer id Column(Integer, primary_keyTrue, autoincrementTrue) assessment_id Column(Integer, ForeignKey(assessment.id), nullableFalse, indexTrue) question_id Column(Integer, nullableTrue) # 仅作参考不设强外键 question_code Column(String(32), nullableFalse) # 题号快照如 q01 option_value Column(Integer, nullableFalse) # 选项分值快照 answered_at Column(DateTime, defaultdatetime.utcnow) class AssessmentResult(Base): __tablename__ assessment_result id Column(Integer, primary_keyTrue, autoincrementTrue) assessment_id Column(Integer, ForeignKey(assessment.id), nullableFalse, indexTrue) dimension_code Column(String(32), nullableFalse) raw_score Column(Float, nullableFalse) std_score Column(Float, nullableTrue) level Column(String(32), nullableTrue)这里最关键的设计是“双快照”。assessment 表存 scale_versionassessment_answer 表存 question_code 和 option_value而不是只存 question_id 外键。为什么因为量表会改版。如果答案表强关联题目表哪天题目顺序调整、选项分值从 5 级改成 4 级历史答卷全部失效。存了快照老答卷可以按老版本计分规则重算甚至重解释。question_id 我保留但允许为 NULL纯粹是为了排查问题时能快速定位题目不承担数据完整性职责。4.2 评分引擎反向计分、按维度聚合、常模查表评分引擎的流程固定为四步读答案 → 按映射表归维度 → 算原始分 → 查常模写结果。反向计分的核心公式是反向分 最大选项值 1 - 原始选项值。注意最大值不要写死成 5要从 question_option 表查当前题目的 max(option_value)否则遇到 4 级选项或 7 级选项的量表就会翻车。from sqlalchemy import func, select from sqlalchemy.orm import Session from models import ( Assessment, AssessmentAnswer, AssessmentResult, ScaleDimension, Question, DimQuestionMapping, QuestionOption, Norm, ) def max_option_value(session: Session, question_id: int) - int: 取一道题的最大选项分值作为反向计分的基准 value session.execute( select(func.max(QuestionOption.option_value)).where( QuestionOption.question_id question_id ) ).scalar() return value if value is not None else 5 def lookup_norm(session: Session, scale_id: int, dimension_code: str, raw_score: float): 按闭区间查常模返回 (标准分, 等级) row session.execute( select(Norm).where( Norm.scale_id scale_id, Norm.dimension_code dimension_code, Norm.raw_lower raw_score, Norm.raw_upper raw_score, ) ).scalars().first() if row is None: return None, None return row.std_score, row.level def score_assessment(session: Session, assessment_id: int): assessment session.get(Assessment, assessment_id) if assessment is None: raise ValueError(fassessment {assessment_id} not found) # 1. 读答案按 question_code 建映射 answers session.execute( select(AssessmentAnswer).where( AssessmentAnswer.assessment_id assessment_id ) ).scalars().all() answer_map {a.question_code: a.option_value for a in answers} # 2. 关联映射表、维度表、题目表拿到每题归属与计分方向 mappings session.execute( select(DimQuestionMapping, ScaleDimension, Question) .join(ScaleDimension, DimQuestionMapping.dimension_id ScaleDimension.id) .join(Question, DimQuestionMapping.question_id Question.id) .where(ScaleDimension.scale_id assessment.scale_id) ).all() # 3. 按维度累加原始分反向题先换算 raw_scores: dict[str, float] {} for mapping, dimension, question in mappings: if question.code not in answer_map: continue # 漏答题跳过正式施测应在提交前拦截漏答 value answer_map[question.code] if mapping.score_direction negative: value max_option_value(session, question.id) 1 - value raw_scores[dimension.code] ( raw_scores.get(dimension.code, 0.0) value * mapping.weight ) # 4. 查常模生成结果记录 results [] for dim_code, raw_score in raw_scores.items(): std_score, level lookup_norm( session, assessment.scale_id, dim_code, raw_score ) results.append(AssessmentResult( assessment_idassessment_id, dimension_codedim_code, raw_scoreround(raw_score, 2), std_scorestd_score, levellevel, )) return results这段代码的边界情况要说明。漏答题目前是 continue 跳过实际项目里提交接口应该在写入 assessment_answer 前校验“已答题数 量表总题数”否则维度原始分会偏低查常模的等级也会失真。lookup_norm 查不到区间时返回 None, None调用处要写日志告警不要静默吞掉这通常是常模表没导入完整或者维度编码写错了。事务边界是这里最容易出问题的地方。正确的提交流程是把全部答案写入 assessment_answer 和 assessment 状态更新放在一个事务里提交然后用评分引擎算分再把结果写入 assessment_result。算分时如果抛异常整个事务回滚assessment 状态回到 in_progress而不是留下一个“已答完但没结果”的脏数据。并发高的时候评分前对 assessment 行加锁PostgreSQL 里用SELECT ... FOR UPDATESQLite 因为锁粒度在数据库文件级别这种场景不建议直接上生产。5. 量表库数据库设计的5个典型避坑点现象、原因与处理这部分是血泪经验汇总。量表库的坑跟普通业务系统的坑不太一样它藏在计分规则和版本语义里前台看数据一切正常报告结果却完全不对。以下 5 条每一条我都见过至少一次。5.1 反向计分方向搞反抑郁量表测出“情绪非常好”现象SDS 施测后大量用户得分异常低全部落在“无抑郁”区间但看原始答卷用户明明选了“5 分几乎所有时间”。原因SDS 有 10 道反向计分题系统把反向题当成正向题累加选 5 分反而加了 5 分整个维度分被稀释掉。解决反向题必须在 dim_question_mapping.score_direction 里标成 negative评分前先做max 1 - value换算。验证方法是有力的一招构造一份全选“1”的答案和一份全选“5”的答案分别跑评分反向题多且方向标对的话两份结果在对应维度上应出现明显的镜像差异再用一组人工手算的原始分对拍确保映射表本身没标错。5.2 题号存文案不编码维度映射总是缺题现象某维度统计出来始终少两道题排查半天发现是题号对不上。原因导入时有人把题号写成了“第1题”“Item 3”这种自由文本还有的题号带全角空格跟映射表里的 q01、q02 编码对不上。解决量表内部统一用零填充编码 q01、q02 到 q90导入脚本里做两件事——校验每个 question 的 dimension 字段必须存在于 dimensions 列表否则抛异常导入完成后跑一条孤儿题查询找出没有被任何维度映射引用的题。SELECT q.code, q.content FROM question q LEFT JOIN dim_question_mapping m ON m.question_id q.id WHERE q.scale_id 1 AND m.id IS NULL;这条 SQL 在接入新量表后必须跑一遍返回零行才能说明维度映射完整。不要相信“我肉眼看过没问题”90 道题的量表肉眼检查必然漏。5.3 量表改版后历史数据全部错位现象某天更新了题目顺序把选项从 5 级改成 4 级第二天用户查看历史报告分数全变了。原因评分时拿新版本的映射表和常模去套老答案题目顺序一变答案表里的 question_id 指向了完全不同的题目。解决assessment 表存 scale_version 快照assessment_answer 存 question_code 和 option_value 快照改版时新建一条 scale 记录、version 递增老记录不删不动评分时基于施测当时的版本取映射关系。记住一句话数据库设计不是给“当前”用的是给“历史当前未来”用的。5.4 常模来源混着用结果解释前后矛盾现象同一张量表、同一个原始分不同时间导入的常模区间不一样查出来的等级从“轻度”变成“中度”统计报告没法解释。原因norm 表没有来源标识某次导入新常模时把旧区间覆盖了或者同一个维度 code 在表里同时存在多套常模区间查询时没限定版本。解决norm 表增加 norm_version 字段配合 scale_id 和 dimension_code 一起查询导入新常模时保留旧版本行用 is_active 标记当前生效版本。另一点必须提醒常模数据是量表版权与使用授权的一部分要从量表配套手册或正规授权渠道获取不要从二手博客、指标源码分享站里抄区间数字来源不明的常模会让整个测评结果失去解释效力。5.5 并发施测时评分结果串到别人头上现象两个人同时提交测评A 的报告里出现了 B 的维度分。原因常见误用是把 answer_map 这种中间数据放到了模块级全局 dict 里做缓存第二个请求进来直接覆盖了第一个的答案另一种是事务范围不对评分函数里读到的 assessment 不是提交那一刻的状态。解决所有测评中间数据只放数据库或函数局部变量绝不放模块全局状态评分和状态更新必须在一个事务内完成先提交答案再算分再 commit高并发场景用数据库行锁保护 assessment 记录PostgreSQL 直接SELECT ... FOR UPDATE。SQLite 对并发写支持有限多用户同时施测的系统一上线就该切 PostgreSQL。6. 用 JSON Schema 驱动量表展示与计分一个可复现的落地技巧6.1 把维度、计分、常模引用打包进一份 schema做到最后你会发现量表的“定义”其实可以脱离数据库表结构单独存在。我把第 3 章的 JSON 格式再往前推一步给它套一个 JSON Schema既做导入校验又让前端拿到同一份文件渲染题目。好处很直接新增量表不需要后端写一个接口不需要前端改一个表单只需要加一个 JSON 文件。{ $schema: http://json-schema.org/draft-07/schema#, type: object, required: [code, name, version, dimensions, questions], properties: { code: {type: string, pattern: ^[a-z][a-z0-9_]*$}, version: {type: string, pattern: ^\\d\\.\\d$}, dimensions: { type: array, items: { type: object, required: [code, name], properties: { code: {type: string}, name: {type: string} } } }, questions: { type: array, items: { type: object, required: [code, content, dimension], properties: { code: {type: string, pattern: ^q\\d{2,}$}, dimension: {type: string}, score_direction: {enum: [positive, negative]} } } } } }Python 侧校验只需几行用 jsonschema 库pip 安装后直接调用。import json import jsonschema from jsonschema import validate # scale_schema 变量指向上面这份 JSON Schema 字典 def load_scale_spec(path: str, scale_schema: dict) - dict: with open(path, encodingutf-8) as f: spec json.load(f) validate(instancespec, schemascale_schema) return spec这段校验有两个参数细节。question code 的 pattern 强制两位以上数字零填充保证排序稳定score_direction 用 enum 限定两种取值拼写错误在导入前就会被拦截。但 JSON Schema 有个天生短板——它无法表达跨字段约束比如“question 里的 dimension 必须存在于 dimensions 列表”这个检查仍要在导入脚本里用 dim_map 查询兜底。也就是说Schema 负责格式校验脚本负责业务引用校验两层都过才允许落库。6.2 换量表和换常模的操作纪律我把这套流程固化成了自己的操作习惯每接入一个新量表提交内容固定为三件套——一份 schema.json 量表定义文件、一个导入脚本调用记录、一组冒烟答案全选 1、全选 5、中间值各一份评审时直接看文件内容和样例分数不再靠嘴对需求。量表改版时复制一份 schemaversion 升号旧文件归档不删如果上线后发现自己标错了计分方向直接把数据库恢复到上一个备份修正 schema 重新导入再评分这就是数据库设计的后悔药。我第一次做量表库时把计分规则写进了服务端的 if/else后来接 SDS 改了整整两天还因为反向题闹出过“重度患者测出乐观”的笑话。那之后我给自己定了一条规矩凡是会随量表改动而变的规则一律进库或进 schema 文件代码里只留算法骨架。希望帮到你。本文还有配套的精品资源点击获取