
简介这套aspnet编写的H5手机端问卷调查平台源码适用于需要快速搭建在线问卷、投票及数据收集系统的企业或个人开发者。系统包含用户注册登录、个人中心、问卷编辑、发布分享、启停管理及结果Excel导出等完整功能支持手机端访问与投票。资源共1232个文件压缩包约38.43MB源码以C#210个cs和ASP.NET页面52个aspx/ashx为主辅以JavaScript、CSS及大量图片素材另有演示视频与说明文档便于前后端对照学习。当前已有838人浏览学习。通过分析该源码可掌握问卷系统从建模、交互到数据统计的完整实现思路尤其适合aspnet初学者进行项目实战也方便二次开发直接复用。1. H5 手机端问卷调查系统的 aspnet 源码值不值得拆开看H5 手机端免费问卷调查平台系统 aspnet 源码听起来是十年前的老技术但它解决的问题到现在都没变企业要发满意度调研门店要让客户扫码填表运营要把回收结果批量导出 Excel。与其从零搭一套 Vue Web API不如先看看这套基于 ASP.NET 通用处理程序的问卷代码把“创建问卷、编辑题目、分享投票、回收结果、导出 Excel”的最小闭环还原出来。源码把controller.ashx、gethomeworkdata.ashx、setresultdata.ashx、getresultdata.ashx这些入口拆得很干净前端用 H5 页面承载编辑和投票后端是免编译的 .ashx 脚本。下面从请求骨架开始沿着编辑、发布、回收、统计的顺序展开最后补上几个直接复用于生产环境前必须做的加固点。2. 拆解 .ashx从 controller.ashx 到 submit_upload.ashx 的请求骨架2.1 为什么 .ashx 在“问卷系统”这类小场景里反而好用在 ASP.NET Web Forms 时代大多数页面是 .aspx带着一套完整的控件生命周期和 ViewState。做问卷这种“前端丢一组参数后端落库再返回 JSON”的场景用 .aspx 属于大炮打蚊子。.ashx是通用处理程序只实现IHttpHandler没有页面状态没有控件树进入ProcessRequest后可以直接操作 Request 和 Response。问卷业务恰恰不需要服务端保留页面状态用户从 H5 页面提交完答案下次进来不需要恢复上次的控件状态。所以这套源码把发布、停用、删除题目、上传图片都拆成独立或半独立的 .ashx 文件这种布局带来的最大优点是排错链路短。gethomeworkdata.ashx出问题就只看它submit_upload.ashx出问题就只看文件上传逻辑。代价则是如果缺少统一基类每个文件可能都要重复做参数校验和异常捕获。常见的做法是后续抽一个BaseHandler先把用户态、日志、异常处理收口再让各个业务类继承。2.2 gethomeworkdata.ashx从查询字符串到 JSON 输出的完整链路2.2.1 ProcessRequest 的最小形态gethomeworkdata.ashx名字里带 homework实际上负责的是把当前用户创建的问卷列表取出来。它接收几个查询参数返回 JSON。核心逻辑是这样的// gethomeworkdata.ashx : 查询当前用户的问卷列表 public class gethomeworkdata : IHttpHandler { public bool IsReusable false; public void ProcessRequest(HttpContext context) { context.Response.ContentType application/json; context.Response.Charset utf-8; // type 决定取创建列表、已发布列表还是回收站 string type context.Request[type]; string uid context.Request[uid]; int page 1; int pageSize 10; if (!int.TryParse(context.Request[page], out page)) page 1; if (!int.TryParse(context.Request[pageSize], out pageSize)) pageSize 10; if (string.IsNullOrEmpty(uid)) { // 缺少用户标识时直接返回错误避免输出空列表误导前端 context.Response.Write({\success\:false,\msg\:\missing uid\}); return; } var list QuestionnaireService.GetMyModels(type, uid, page, pageSize); context.Response.Write(JsonConvert.SerializeObject(list)); } }这段代码的逻辑说明ProcessRequest是整个处理程序的唯一入口每次 HTTP 请求都会走进这里。context.Request[type]同时兼容 query string 和 form body所以前端 GET 或 POST 都能命中。int.TryParse比直接强转安全因为 H5 端在弱网环境可能发出空参数。GetMyModels是业务层方法这里没有把 SQL 写在 handler 里是为了后面维护时能统一替换 ORM。2.2.2 参数说明与异常分支参数取值行为typeowned / published / deletedowned 查询当前用户创建的全部问卷published 只查已发布deleted 查回收站uid用户唯一标识为空时返回success:falsepage / pageSizeint控制分页pageSize 不建议超过 100手机端一次渲染 100 条以上会明显掉帧keyword可选字符串常见用法是在 where 子句中对问卷标题做模糊查询前端编辑器的搜索框经常会传keyword后台如果没有对特殊字符做转义用户在 H5 搜索框里输入%会把整张表扫一遍。这里建议在 SQL 里用LIKE kw ESCAPE !并把%、_、!先替换掉。2.3 controller.ashx 的操作分发把“删除/停用/启停”收口到一个入口写操作如果每个都单建一个 .ashx文件会越来越多。源码里用controller.ashx作为总入口通过action参数做分发。后端常见的写法是这样// controller.ashx 的动作分发 switch (context.Request[action]) { case publish: // 把问卷状态置为 1 QuestionnaireService.UpdateStatus(id, 1); context.Response.Write({\success\:true,\msg\:\published\}); break; case recycle: // 停用问卷不影响已回收到的结果 QuestionnaireService.UpdateStatus(id, 0); context.Response.Write({\success\:true,\msg\:\recycled\}); break; case saveModel: // 保存整个问卷模型包括题目和选项 QuestionnaireService.SaveModel(context.Request.Form[modelJson]); context.Response.Write({\success\:true,\msg\:\saved\}); break; case delete: // 逻辑删除和 deletemodel.ashx 共用同一个服务方法 QuestionnaireService.SoftDelete(id); context.Response.Write({\success\:true,\msg\:\deleted\}); break; default: context.Response.Write({\success\:false,\msg\:\unknown action\}); break; }对应的 H5 编辑页调用方式如下// H5 编辑页停用问卷时只发一个 actionrecycle fetch(controller.ashx, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded }, body: actionrecycleid1024 }) .then(r r.json()) .then(res { if (res.success) location.reload(); });逻辑说明这里的action是业务语义不是文件名。前端换了交互界面比如从列表左滑变成底部弹窗动作仍然是recycle后台不需要新增接口。参数说明id是问卷主键后端在 switch 之前一定要先判断用户是否登录因为这种统一入口一旦暴露任何人都可以构造 POST 请求批量停用问卷。2.4 getdistinctdata.ashx 的 distinct 用法避免问卷选项统计被重复计数做问卷后台时经常需要在筛选器里展示“已经有哪些部门参与”“有哪些标签”。getdistinctdata.ashx承担的就是这个任务。它的 SQL 核心是 distinct这是最简单也最容易用错的统计手段。-- 从提交明细中取出去重后的部门列表 SELECT DISTINCT department FROM survey_answer WHERE model_id modelId AND deleted 0 ORDER BY department; -- 统计每个选项对应的去重用户数 SELECT option_id, COUNT(DISTINCT user_id) AS user_cnt FROM survey_answer WHERE model_id modelId GROUP BY option_id;逻辑说明第一个查询用 distinct 拿到可选维度供前端下拉筛选第二个查询用COUNT(DISTINCT user_id)统计参与人数。这里要注意的是distinct 能去掉字段组合的重复但无法解决“同一个用户刷了三次问卷”的问题。更多时候需要在setresultdata.ashx写入阶段做幂等控制而不是在查询阶段用 distinct 掩盖数据重复。另外如果业务里维护了部门维度删除部门则通过deletedepart.ashx处理必须先把该部门下已有的回答记录外键置空或迁移否则会被约束卡住。3. 编辑界面与题目生命周期deletemodel、deletelabel、submit_upload 的协作方式3.1 问卷结构怎么建模model、label 是两张表而不是一个 JSON 字段在源码里能看到单独的deletemodel.ashx和deletelabel.ashx说明项目没有把整份问卷塞进一个超长 JSON 字段。问卷主体抽象成model题目里的选项或标签抽象成label两张表通过model_id关联。这样设计的好处是 H5 编辑页拿到数据后可以直接渲染成两个数组用户改单选、多选、文本题的选项时前端只需要维护一个轻量对象。删除顺序是必须先删 label 再删 model。常见写法是放在事务里// deletemodel.ashx : 删除问卷模型及其所有选项 int modelId Convert.ToInt32(context.Request[modelId]); string userId context.Request[userId]; string connStr ConfigurationManager.ConnectionStrings[Default].ConnectionString; using (var conn new SqlConnection(connStr)) { conn.Open(); using (var tx conn.BeginTransaction()) { // 先删除依赖的选项表避免外键冲突 SqlHelper.ExecuteNonQuery(tx, DELETE FROM survey_label WHERE model_idmodelId, modelId); // 再删除主表并校验创建人 SqlHelper.ExecuteNonQuery(tx, DELETE FROM survey_model WHERE model_idmodelId AND creatoruserId, modelId, userId); tx.Commit(); } }逻辑说明事务保证两个删除要么都成功要么都回滚。creatoruserId的拼接即权限校验条件不满足时影响行数为 0前端只要检查返回的success就能提示“无权删除”。这里的SqlHelper通常是简单封装SqlCommand的辅助类参数化查询必须保留不能用字符串拼接modelId。入口操作对象边界处理deletelabel.ashx删除某个选项同步清理 survey_answer 中引用该选项的旧记录deletemodel.ashx删除整份问卷必须提前删除所有 label再删除 modeldeletedepart.ashx删除部门维度先处理部门下已有答题数据再做物理释放3.2 H5 编辑界面的“保存一步 提交一次”模式这套问卷后台的前端走的是“草稿在内存中维护点击发布时一次性提交”模式没有做细粒度自动保存。这样做并不是技术落后而是在手机端频繁的自动保存会消耗用户流量也会把后台接口打满。const draft { title: 展会满意度调研, labels: [ { type: radio, text: 整体评价, options: [好, 一般, 差] }, { type: textarea, text: 建议, options: [] } ] }; function submitModel() { fetch(controller.ashx?actionsaveModel, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(draft) }) .then(r r.json()) .then(data { if (data.success) { // 发布成功后把分享链接回填给编辑人员 document.getElementById(shareUrl).value vote.html?mid${data.modelId}; } }); }这里的逻辑说明draft是前端的内存态点“发布问卷”时才把整个问卷结构 JSON 化提交。后端把 JSON 拆解成 model 和 label 写入两张表。参数说明返回结果里必须带modelId前端分享链接要拿它拼出 H5 投票页地址。如果用户编辑到一半切到后台未点击发布草稿不会落库这是合理的业务语义。3.3 submit_upload.ashx 做图片上传时文件重命名和大小限制是最容易出事的点问卷题目里经常出现图片选项比如“选择你最喜欢的包装风格”。submit_upload.ashx接收multipart/form-data上传最容易踩坑的是直接用原始文件名保存中文文件名会导致乱码带路径的名字还可能被构造为路径穿越。// submit_upload.ashx : 保存题目图片并返回访问路径 HttpPostedFile file context.Request.Files[file]; if (file null || file.ContentLength 2 * 1024 * 1024) { context.Response.Write({\success\:false,\msg\:\file invalid\}); return; } string ext Path.GetExtension(file.FileName).ToLower(); string newName Guid.NewGuid().ToString(N) ext; string saveDir context.Server.MapPath(~/upload/); file.SaveAs(Path.Combine(saveDir, newName)); context.Response.Write({\success\:true,\path\:\/upload/ newName \});逻辑说明文件名用Guid.NewGuid()重新生成扩展名做白名单过滤例如只允许.jpg、.png、.gif。上传目录和站点根目录分离。ContentLength判断的是请求体大小不能完全防止超大图因为服务器可能已经分块接收了一部分更好的做法是前端在上传前先用 canvas 压缩。注意IIS 默认请求体限制很紧如果 H5 端要传大图需要在 web.config 的system.webServer节点调整maxAllowedContentLength否则会收到 404.13 而不是正常的业务错误。4. 投票、结果回捞与 Excel 导出setresultdata、getresultdata 核心数据流4.1 答题提交的幂等设计setresultdata.ashx 防止 H5 页面重复提交用户从手机端分享链接进入 H5 投票页以后填完问卷点提交走的入口是setresultdata.ashx。这个接口必须做幂等否则用户快速点两次提交后台会收到两条相同数据。源码里的常见做法是提交前先做去重判断。// setresultdata.ashx : 提交答题结果前先判断是否已有相同记录 string uid context.Request[userId]; string modelId context.Request[modelId]; string deviceId context.Request[deviceId]; string checkSql SELECT COUNT(1) FROM survey_answer WHERE user_iduid AND model_idmodelId AND device_iddeviceId; int exists Convert.ToInt32(SqlHelper.ExecuteScalar(checkSql, uid, modelId, deviceId)); if (exists 0) { context.Response.Write({\success\:false,\msg\:\duplicated submit\}); return; } // 真正写入答案的业务逻辑从这里开始对应的 H5 端在页面初始化时生成设备标记// 同一手机在同一浏览器里保持一个设备标记 let deviceId localStorage.getItem(survey_device); if (!deviceId) { deviceId Date.now().toString(36) _ Math.random().toString(16).slice(2); localStorage.setItem(survey_device, deviceId); }这个设计的边界在于localStorage会被用户清掉换浏览器或开无痕模式后指标失效。更严格的做法是后台配合答题令牌用户打开问卷时先请求一个 token提交时带着 token一次使用用过即弃。如果业务允许“活动期只答一次”那可以用submit_delete.ashx人工清理异常记录但不能依赖它兜底。4.2 结果统计在前端聚合还是后端聚合getresultdata.ashx 的数据表结构结果查看页要的是“每道题哪些人选了哪些选项”数据基本来自getresultdata.ashx。如果在前端逐行遍历所有答案再分组百万条记录里浏览器基本转不动。后端聚合更合适常见数据结构如下字段说明id自增主键user_id答题用户model_id问卷 IDquestion_id题目 IDoption_id选项 IDanswer_text文本题内容created_at提交时间后端拿到明细表后在内存里做一次分组再返回 JSON// getresultdata.ashx 中按题号进行统计 var rows resultTable.AsEnumerable() .GroupBy(r new { QuestionId r.Fieldint(question_id), Title r.Fieldstring(title) }) .Select(g new { questionId g.Key.QuestionId, title g.Key.Title, optionStats g.GroupBy(o o.Fieldstring(option_text)) .Select(o new { key o.Key, count o.Count() }) }); context.Response.Write(JsonConvert.SerializeObject(rows));逻辑说明先按题目分组再按选项文本聚合。这样前端拿到的数据已经是“结果汇总”可以直接画饼图或条形图。参数说明getresultdata.ashx建议带modelId、startTime、endTime三个过滤条件否则活动结束后统计人员还要在 Excel 里自己筛时间范围。4.3 从 JSON 到本地 Excel三种导出方案方案适用场景优点主要坑生成 CSV结果量少于一千行零依赖代码简单中文乱码需要写 UTF-8 BOMNPOI常见企业 Excel 导出样式可控支持 xlsx大数据量要分批写入SQL Server 导出数据库直接出文件性能最好非 Windows 环境基本无法部署如果只是在 .NET Framework 项目里快速实现“下载 Excel”常见做法是 NPOI// 用 NPOI 生成 xlsx 并输出 using NPOI.XSSF.UserModel; using NPOI.SS.UserModel; XSSFWorkbook wb new XSSFWorkbook(); ISheet sheet wb.CreateSheet(result); IRow header sheet.CreateRow(0); header.CreateCell(0).SetCellValue(题目); header.CreateCell(1).SetCellValue(选项); header.CreateCell(2).SetCellValue(选中人数); using (MemoryStream ms new MemoryStream()) { wb.Write(ms); context.Response.Clear(); context.Response.ContentType application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; context.Response.AddHeader(Content-Disposition, attachment;filenameresult.xlsx); context.Response.BinaryWrite(ms.ToArray()); context.Response.End(); }逻辑说明Content-Type必须匹配 xlsx否则 Excel 打开时会提示文件损坏。要注意的是context.Response.End()会抛出ThreadAbortException很多新手看到异常日志里全是这个直接忽略即可。4.4 当问卷突然有 10 万条提交结果时怎么办一旦活动铺到全国门店答题记录会快速突破十万行。getresultdata.ashx如果还在用SELECT * FROM survey_answer数据库会全表扫描页面进入统计时卡在接口等待。分页是必须的但不是只给前端分页后端也要限制每批读取数量。-- 分页读取使用 ROW_NUMBER 避免 OFFSET 在大表上性能下降 SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (ORDER BY created_at DESC) AS rn FROM survey_answer WHERE model_id modelId ) t WHERE rn BETWEEN start AND end这个查询配合(model_id, created_at)联合索引十万条数据基本能在几十毫秒内返回一页。真正的瓶颈通常在导出 Excel建议导出变成异步任务先写入一个export_task表后台 Job 生成文件后再通知下载。前端 H5 页面在这段时间只要轮询任务状态即可。5. 拿到这套 aspnet 源码后建议先做的三处加固5.1 统一包装所有 .ashx 的异常与日志现在的入口各自为战有的漏写try/catch出错后直接抛堆栈H5 端拿到的是 500 页面。接手后先做一个基类// BaseHandler.cs : 统一异常处理和响应格式 public abstract class BaseHandler : IHttpHandler { public bool IsReusable false; public void ProcessRequest(HttpContext context) { try { Execute(context); } catch (Exception ex) { context.Response.ContentType application/json; context.Response.Write({\success\:false,\msg\:\server error\}); LogHelper.Error(handler_error, ex); } } protected abstract void Execute(HttpContext context); }之后所有业务 handler 继承BaseHandler只实现Execute(context)。前端也统一从返回 JSON 的msg中读取错误文案而不是解析服务端人员看到的英文异常。5.2 把管理端入口从公开路径挪到配置化controller.ashx这类管理入口如果直接挂在根路径攻击者很容易枚举 action。更稳妥的做法是给管理入口单独挂一个虚拟目录或子应用并在 ProcessRequest 里追加会话校验// controller.ashx 开头校验管理员身份 if (context.Session[admin] null) { context.Response.ContentType application/json; context.Response.Write({\success\:false,\msg\:\no login\}); return; }这个修改不复杂但能拦住大多数未经授权的访问。如果这套系统还要给多个企业做 SaaS建议把admin前缀做成配置文件字段发布时每个客户的路径不同。5.3 微信内嵌 H5 的兼容改造UA 判断与定位授权很多人会把问卷链接发到微信群微信内置浏览器的兼容方式跟普通 H5 不一样。常见做法是先判断 UA// 判断是否在微信内置浏览器中打开 if (/MicroMessenger/i.test(navigator.userAgent)) { // 走微信 OAuth 换取 openid作为匿名用户标识 location.href /wechat/oauth.ashx?back encodeURIComponent(location.href); }如果问卷里需要定位不能直接用navigator.geolocation在 iOS 微信里很可能静默失败。常见做法是先调用微信 JS-SDK 的wx.getLocation这需要服务端先做签名鉴权。改造时把定位结果作为隐藏字段随setresultdata.ashx一起提交统计报表里就能按城市维度分析数据。5.4 验证发布用 curl 模拟普通用户投票二次开发完成之后不要只点浏览器界面用命令行把闭环跑一遍更能暴露问题。# 1. 创建一份测试问卷 curl -X POST http://yourhost/controller.ashx \ --data actionsaveModeltitle测试modelJson{%22title%22:%22test%22} # 2. 以 guest01 身份提交一份答案 curl -X POST http://yourhost/setresultdata.ashx \ --data userIdguest01modelId1024deviceIddev-001answertest # 3. 拉取结果确认 total 为 1 curl http://yourhost/getresultdata.ashx?modelId1024page1limit10再次执行第二条 curl观察返回的 total 是否仍是 1。如果 total 变成 2说明setresultdata.ashx的幂等键没有真正生效此时优先检查deviceId是否在两次请求里保持一致以及去重 SQL 中的user_id是否被服务端覆盖成了真实登录态。本文还有配套的精品资源点击获取