H5问卷调查系统ASP.NET源码解析:从ashx到Excel导出全流程
2026/9/16 2:46:36 网站建设 项目流程

简介:这套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.ashxgethomeworkdata.ashxsetresultdata.ashxgetresultdata.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:false
page / 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 编辑页停用问卷时,只发一个 action=recycle fetch('controller.ashx', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'action=recycle&id=1024' }) .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.ashxdeletelabel.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_id=@modelId", modelId); // 再删除主表,并校验创建人 SqlHelper.ExecuteNonQuery(tx, "DELETE FROM survey_model WHERE model_id=@modelId AND creator=@userId", modelId, userId); tx.Commit(); } }

逻辑说明:事务保证两个删除要么都成功,要么都回滚。creator=@userId的拼接即权限校验,条件不满足时影响行数为 0,前端只要检查返回的success,就能提示“无权删除”。这里的SqlHelper通常是简单封装SqlCommand的辅助类,参数化查询必须保留,不能用字符串拼接modelId

入口操作对象边界处理
deletelabel.ashx删除某个选项同步清理 survey_answer 中引用该选项的旧记录
deletemodel.ashx删除整份问卷必须提前删除所有 label,再删除 model
deletedepart.ashx删除部门维度先处理部门下已有答题数据,再做物理释放

3.2 H5 编辑界面的“保存一步 + 提交一次”模式

这套问卷后台的前端走的是“草稿在内存中维护,点击发布时一次性提交”模式,没有做细粒度自动保存。这样做并不是技术落后,而是在手机端频繁的自动保存会消耗用户流量,也会把后台接口打满。

const draft = { title: '展会满意度调研', labels: [ { type: 'radio', text: '整体评价', options: ['好', '一般', '差'] }, { type: 'textarea', text: '建议', options: [] } ] }; function submitModel() { fetch('controller.ashx?action=saveModel', { 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_id=@uid AND model_id=@modelId AND device_id=@deviceId"; 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问卷 ID
question_id题目 ID
option_id选项 ID
answer_text文本题内容
created_at提交时间

后端拿到明细表后,在内存里做一次分组再返回 JSON:

// getresultdata.ashx 中按题号进行统计 var rows = resultTable.AsEnumerable() .GroupBy(r => new { QuestionId = r.Field<int>("question_id"), Title = r.Field<string>("title") }) .Select(g => new { questionId = g.Key.QuestionId, title = g.Key.Title, optionStats = g.GroupBy(o => o.Field<string>("option_text")) .Select(o => new { key = o.Key, count = o.Count() }) }); context.Response.Write(JsonConvert.SerializeObject(rows));

逻辑说明:先按题目分组,再按选项文本聚合。这样前端拿到的数据已经是“结果汇总”,可以直接画饼图或条形图。参数说明:getresultdata.ashx建议带modelIdstartTimeendTime三个过滤条件,否则活动结束后统计人员还要在 Excel 里自己筛时间范围。

4.3 从 JSON 到本地 Excel:三种导出方案

方案适用场景优点主要坑
生成 CSV结果量少于一千行零依赖,代码简单中文乱码,需要写 UTF-8 BOM
NPOI常见企业 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;filename=result.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 'action=saveModel&title=测试&modelJson={%22title%22:%22test%22}' # 2. 以 guest01 身份提交一份答案 curl -X POST 'http://yourhost/setresultdata.ashx' \ --data 'userId=guest01&modelId=1024&deviceId=dev-001&answer=test' # 3. 拉取结果,确认 total 为 1 curl 'http://yourhost/getresultdata.ashx?modelId=1024&page=1&limit=10'

再次执行第二条 curl,观察返回的 total 是否仍是 1。如果 total 变成 2,说明setresultdata.ashx的幂等键没有真正生效;此时优先检查deviceId是否在两次请求里保持一致,以及去重 SQL 中的user_id是否被服务端覆盖成了真实登录态。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询