diff --git a/src/main/java/io/v/nutz/brandtrainactivity/controller/manage/BrandTrainSignUpActivityAddController.java b/src/main/java/io/v/nutz/brandtrainactivity/controller/manage/BrandTrainSignUpActivityAddController.java index 3e42220..43d6899 100644 --- a/src/main/java/io/v/nutz/brandtrainactivity/controller/manage/BrandTrainSignUpActivityAddController.java +++ b/src/main/java/io/v/nutz/brandtrainactivity/controller/manage/BrandTrainSignUpActivityAddController.java @@ -5,12 +5,15 @@ import io.v.nutz.annontation.ViReturn; import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpActivity; import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpUser; import io.v.nutz.brandtrainactivity.service.BrandTrainSignUpActivityService; +import io.v.nutz.welfare.service.WelfareProjectService; import lombok.extern.slf4j.Slf4j; import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.dao.Sqls; import org.nutz.dao.sql.Sql; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.json.Json; @@ -38,16 +41,26 @@ public class BrandTrainSignUpActivityAddController { @Inject private BrandTrainSignUpActivityService brandTrainSignUpActivityService; + @Inject + private WelfareProjectService welfareProjectService; + @At("/add") @RequiresPermissions("brandTrainSingUp.manage.activity.add") @Ok("beetl:/platform/brandTrainSingUp/manage/activity_add.html") public void add() { } + /** + * 保存品牌活动及课程配置,新增与编辑共用同一份活动JSON。 + * + * @param data 品牌活动实体JSON,包含课程列表、范围限制及可选的签到扩展配置;id为空时新增 + * @return 无业务数据,由ViReturn包装为包含code、msg、data的操作结果 + */ @At @ViReturn @Ok("json:full") @POST + @Aop(TransAop.READ_COMMITTED) @RequiresPermissions("brandTrainSingUp.manage.activity.add") public Object doHandle(@Param(value = "data", required = false) String data) { BrandTrainSignUpActivity activity = Json.fromJson(BrandTrainSignUpActivity.class, data); @@ -99,4 +112,18 @@ public class BrandTrainSignUpActivityAddController { List query = dao.query(BrandTrainSignUpActivity.class, Cnd.NEW().desc("activityStartTime")); return query; } + + /** + * 获取品牌活动签到达标后可引导选择的福利项目,本接口不接收参数。 + * + * @return 福利项目列表,由ViReturn包装为code、msg、data;data每项的id、name供表单选择 + */ + @At + @POST + @ViReturn + @Ok("json:full") + @RequiresPermissions("brandTrainSingUp.manage.activity") + public Object getAvailableWelfareProjectList() { + return welfareProjectService.getAvailableSignWelfareProjects(); + } } diff --git a/src/main/java/io/v/nutz/brandtrainactivity/controller/mobile/BrandTrainScanCodeController.java b/src/main/java/io/v/nutz/brandtrainactivity/controller/mobile/BrandTrainScanCodeController.java new file mode 100644 index 0000000..4725683 --- /dev/null +++ b/src/main/java/io/v/nutz/brandtrainactivity/controller/mobile/BrandTrainScanCodeController.java @@ -0,0 +1,110 @@ +package io.v.nutz.brandtrainactivity.controller.mobile; + +import cn.hutool.core.util.StrUtil; +import io.v.nutz.base.result.Result; +import io.v.nutz.brandtrainactivity.service.BrandTrainSignUpActivityService; +import io.v.nutz.web.commons.utils.ShiroUtil; +import org.apache.shiro.authz.annotation.RequiresAuthentication; +import org.nutz.aop.interceptor.ioc.TransAop; +import org.nutz.ioc.aop.Aop; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.lang.util.NutMap; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.POST; +import org.nutz.mvc.annotation.Param; +import org.nutz.trans.Trans; + +/** 已开启签到扩展的品牌活动点位扫码入口,登录与页面渲染沿用当前手机端方式。 */ +@IocBean +@At("/platform/mobile/brandTrain/scanCode") +@Ok("json:full") +public class BrandTrainScanCodeController { + + @Inject + private BrandTrainSignUpActivityService brandTrainSignUpActivityService; + + @At("") + @RequiresAuthentication + @Ok("beetl:/mobile/brandTrain/scanCode.html") + public void scanCode() { + } + + /** + * @param courseId 二维码携带的课程ID,用户ID从当前登录态获取 + * @return Result的code/msg/data;code=0时data包含签到地点坐标、当前点位签到状态、活动进度及达标事项 + */ + @At + @POST + @RequiresAuthentication + public Result getSignData(@Param("courseId") String courseId) { + if (StrUtil.isBlank(courseId)) { + return Result.error(99, "缺少签到点位信息"); + } + try { + NutMap data = brandTrainSignUpActivityService.getScanCodeSignData(courseId, ShiroUtil.getPlatformUid()); + return Result.success().addData(data); + } catch (IllegalArgumentException e) { + return Result.error(99, e.getMessage()); + } + } + + /** + * 用户在签到地图中手动确认签到,浏览页面及获取定位本身均不写入签到记录。 + * + * @param courseId 二维码携带的品牌课程ID,当前用户从服务端登录态取得 + * @param longitude 当前腾讯定位的经度,须与地图显示点位同为GCJ-02坐标 + * @param latitude 当前定位的纬度,与经度共同用于服务端配置半径的范围校验 + * @return Result的code/msg/data;0签到成功,99业务提示,已签到时data仍保留当前状态和达标事项 + */ + @At + @POST + @RequiresAuthentication + @Aop(TransAop.READ_COMMITTED) + public Result doSign(@Param("courseId") String courseId, @Param("longitude") Double longitude, + @Param("latitude") Double latitude) { + if (StrUtil.isBlank(courseId)) { + return Result.error(99, "缺少签到点位信息"); + } + if (longitude == null || latitude == null) { + return Result.error(99, "请先获取当前定位后再签到"); + } + try { + NutMap result = brandTrainSignUpActivityService.handleScanCodeSign(courseId, ShiroUtil.getPlatformUid(), longitude, latitude); + return Result.NEW().addCode(result.getInt("code")).addMsg(result.getString("msg")).addData(result.get("data")); + } catch (IllegalArgumentException e) { + // 当前Nutz嵌套事务未提供rollbackOnly;转换业务响应前回滚当前连接,保留外层AOP的事务计数。 + if (Trans.get() != null) { + brandTrainSignUpActivityService.dao().run(connection -> connection.rollback()); + } + return Result.error(99, e.getMessage()); + } + } + + /** + * @param courseId 当前扫码课程ID,用于定位活动及动态字段配置 + * @param mobileColumnsValue JSON数组,每项包含columnCode及columnValue;附件值保留数组结构 + * @return Result,code=0表示保存成功,code=99表示权限、达标或字段校验未通过 + */ + @At + @POST + @RequiresAuthentication + @Aop(TransAop.READ_COMMITTED) + public Result saveDynamicForm(@Param("courseId") String courseId, + @Param("mobileColumnsValue") String mobileColumnsValue) { + if (StrUtil.isBlank(courseId) || StrUtil.isBlank(mobileColumnsValue)) { + return Result.error(99, "请填写当前签到点位的信息"); + } + try { + brandTrainSignUpActivityService.saveScanCodeDynamicForm(courseId, ShiroUtil.getPlatformUid(), mobileColumnsValue); + return Result.success("保存成功"); + } catch (IllegalArgumentException e) { + // 显式回滚已加入外层事务的写入,避免捕获service异常后被controller外层提交。 + if (Trans.get() != null) { + brandTrainSignUpActivityService.dao().run(connection -> connection.rollback()); + } + return Result.error(99, e.getMessage()); + } + } +} diff --git a/src/main/java/io/v/nutz/brandtrainactivity/controller/mobile/MBrandTrainSignUpActivityController.java b/src/main/java/io/v/nutz/brandtrainactivity/controller/mobile/MBrandTrainSignUpActivityController.java index 708ec00..3c96fd5 100644 --- a/src/main/java/io/v/nutz/brandtrainactivity/controller/mobile/MBrandTrainSignUpActivityController.java +++ b/src/main/java/io/v/nutz/brandtrainactivity/controller/mobile/MBrandTrainSignUpActivityController.java @@ -11,10 +11,12 @@ import io.v.nutz.data.service.MatchConditionStructureService; import io.v.nutz.modules.models.PageForm; import io.v.nutz.web.commons.utils.ShiroUtil; import org.apache.shiro.authz.annotation.RequiresAuthentication; +import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Chain; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; import org.nutz.integration.jedis.RedisService; +import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.util.NutMap; @@ -166,27 +168,24 @@ public class MBrandTrainSignUpActivityController { return null; } + /** + * GPS签到,半径与坐标以服务端课程配置为准。 + * @param id 当前登录用户的课次签到记录ID + * @param courseId 当前课程ID,service核对其与签到记录的对应关系 + * @param points 当前GCJ-02定位坐标,JSON数组[经度,纬度] + * @return ViReturn包装的code/msg/data;成功无业务data,参数或距离错误返回业务提示 + */ @At @Ok("json:full") @POST @ViReturn @RequiresAuthentication + @Aop(TransAop.READ_COMMITTED) public Object doQd(@Param("id") String id, @Param("courseId") String courseId, @Param("point") Double[] points) { - BrandTrainSignUpCourse course = dao.fetch(BrandTrainSignUpCourse.class, courseId); - List coursePoints = course.getCourseLocationCoordinates(); -// if (Lang.isNotEmpty(coursePoints)) { -// //需要签到 -// if (ArrayUtil.isEmpty(points) || ArrayUtil.hasNull(points)) { -// return Result.error().addMsg("请获取当前的坐标信息"); -// } -// Double[] coursePointArray = coursePoints.toArray(new Double[]{}); -// float distance = AMapUtils.calculateLineDistance(new LatLng(points[0], points[1]), new LatLng(coursePointArray[0], coursePointArray[1])); -// -// if (distance > 500) { -// return Result.error().addMsg("请到签到点位附近签到"); -// } -// } - brandTrainSignUpActivityService.doQd(id); + if (StrUtil.isBlank(id) || StrUtil.isBlank(courseId) || points == null || points.length != 2) { + return Result.error("请获取当前定位后再签到"); + } + brandTrainSignUpActivityService.doQd(id, courseId, ShiroUtil.getPlatformUid(), points[0], points[1], 3); return null; } @@ -285,19 +284,34 @@ public class MBrandTrainSignUpActivityController { } } + /** + * 旧课程二维码签到,与GPS签到复用service的用户归属和配置半径校验。 + * @param id 当前登录用户的课次签到记录ID + * @param codeCourseId 二维码解析出的课程ID + * @param clickCourseId 页面点击的课程ID,必须与二维码课程一致 + * @param longitude 当前GCJ-02定位经度 + * @param latitude 当前GCJ-02定位纬度 + * @return ViReturn包装的code/msg/data;成功无业务data,校验失败返回业务提示 + */ @At @Ok("json:full") + @POST @ViReturn @RequiresAuthentication - public Object codeSign(String id, String codeCourseId, String clickCourseId) { - if(StrUtil.isBlank(codeCourseId) || StrUtil.isBlank(clickCourseId)) { + @Aop(TransAop.READ_COMMITTED) + public Object codeSign(@Param("id") String id, @Param("codeCourseId") String codeCourseId, + @Param("clickCourseId") String clickCourseId, @Param("longitude") Double longitude, + @Param("latitude") Double latitude) { + if(StrUtil.isBlank(id) || StrUtil.isBlank(codeCourseId) || StrUtil.isBlank(clickCourseId)) { return Result.error("签到失败,没有获取到扫描信息"); } if(!codeCourseId.equals(clickCourseId)) { return Result.error("签到失败,二维码与您当前签到信息不符"); } - dao.update(BrandTrainSignUpUserCourse.class, Chain.make("isAttend", true) - .add("attendTime", new Date()), Cnd.where("id", "=", id)); + if (longitude == null || latitude == null) { + return Result.error("请获取当前定位后再签到"); + } + brandTrainSignUpActivityService.doQd(id, clickCourseId, ShiroUtil.getPlatformUid(), longitude, latitude, 1); return null; } } diff --git a/src/main/java/io/v/nutz/brandtrainactivity/controller/statistics/BrandTrainSignUpActivityStatisticsController.java b/src/main/java/io/v/nutz/brandtrainactivity/controller/statistics/BrandTrainSignUpActivityStatisticsController.java index fbc397d..802c3a3 100644 --- a/src/main/java/io/v/nutz/brandtrainactivity/controller/statistics/BrandTrainSignUpActivityStatisticsController.java +++ b/src/main/java/io/v/nutz/brandtrainactivity/controller/statistics/BrandTrainSignUpActivityStatisticsController.java @@ -1,5 +1,8 @@ package io.v.nutz.brandtrainactivity.controller.statistics; +import io.v.nutz.base.result.Result; +import io.v.nutz.base.utils.CommonDownloadUtil; + import cn.afterturn.easypoi.excel.entity.ExportParams; import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; @@ -347,4 +350,27 @@ public class BrandTrainSignUpActivityStatisticsController { zipOutputStream.flush(); zipOutputStream.close(); } + + /** + * 下载活动签到后填写的信息,不接收人员ID或动态字段定义。 + * + * @param activityId 活动主键,导出该活动下按人员合并的填写记录 + * @param response 成功返回xlsx文件流;参数错误返回包含code、msg的JSON对象 + * @throws IOException 无法写入HTTP响应时抛出 + */ + @At + @Ok("void") + @RequiresPermissions("brandTrainSingUp.statistics.activity") + public void exportFillForm(@Param("activityId") String activityId, + HttpServletResponse response) throws IOException { + try { + Workbook workbook = brandTrainSignUpActivityStatisticsService.exportFillFormWorkbook(activityId); + CommonDownloadUtil.download("填写信息.xlsx", workbook, response); + } catch (IllegalArgumentException e) { + response.reset(); + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json"); + response.getWriter().write(Result.error(99, e.getMessage()).toJsonString()); + } + } } diff --git a/src/main/java/io/v/nutz/brandtrainactivity/models/BrandTrainSignUpActivity.java b/src/main/java/io/v/nutz/brandtrainactivity/models/BrandTrainSignUpActivity.java index 1e67bfc..a01e62a 100644 --- a/src/main/java/io/v/nutz/brandtrainactivity/models/BrandTrainSignUpActivity.java +++ b/src/main/java/io/v/nutz/brandtrainactivity/models/BrandTrainSignUpActivity.java @@ -118,4 +118,19 @@ public class BrandTrainSignUpActivity extends BaseModel implements Serializable @Comment("活动类型") @ColDefine(type = ColType.VARCHAR, width = 20) private String trainType; + + @Column + @ColDefine(type = ColType.INT) + @Comment("签到达标点位数,空或0表示未开启签到扩展") + private Integer signPointCount; + + @Column + @ColDefine(type = ColType.INT) + @Comment("签到达标处理方式:1填写信息,2选择福利,3无") + private Integer signHandleType; + + @Column + @ColDefine(type = ColType.VARCHAR, width = 32) + @Comment("签到达标后关联的福利项目ID") + private String welfareProjectId; } diff --git a/src/main/java/io/v/nutz/brandtrainactivity/models/BrandTrainSignUpCourse.java b/src/main/java/io/v/nutz/brandtrainactivity/models/BrandTrainSignUpCourse.java index dee7553..9108544 100644 --- a/src/main/java/io/v/nutz/brandtrainactivity/models/BrandTrainSignUpCourse.java +++ b/src/main/java/io/v/nutz/brandtrainactivity/models/BrandTrainSignUpCourse.java @@ -63,6 +63,11 @@ public class BrandTrainSignUpCourse extends BaseModel implements Serializable { @Comment("课程地点坐标") private List courseLocationCoordinates; + @Column + @ColDefine(type = ColType.INT) + @Comment("签到点位半径,单位米;未配置时使用250米") + private Integer signRadius; + @Column @ColDefine(type = ColType.VARCHAR, width = 20) @Comment("课程讲师") diff --git a/src/main/java/io/v/nutz/brandtrainactivity/service/BrandTrainSignUpActivityService.java b/src/main/java/io/v/nutz/brandtrainactivity/service/BrandTrainSignUpActivityService.java index 2e4a973..66e22fa 100644 --- a/src/main/java/io/v/nutz/brandtrainactivity/service/BrandTrainSignUpActivityService.java +++ b/src/main/java/io/v/nutz/brandtrainactivity/service/BrandTrainSignUpActivityService.java @@ -113,11 +113,17 @@ public interface BrandTrainSignUpActivityService extends ViService qdInfoByUserId(String userId, String activityId); + /** + * 只读预览扫码点位、当前点位签到状态和活动进度,不补报名、不签到、不新增福利名单。 + * + * @param courseId 品牌课程ID,仅支持已开启签到扩展的扫码签到课程 + * @param userId 当前登录用户ID,由服务端登录态取得 + * @return data对象;courseLocationCoordinates为[经度,纬度],currentPointSigned为当前课程是否已签,signRadius为米数 + */ + NutMap getScanCodeSignData(String courseId, String userId); + + /** + * 当前用户在地图页手动签到,校验定位后累计不重复的课程点位,并处理原有达标事项。 + * + * @param courseId 品牌课程ID,不接受普通培训课程或非扫码签到课程 + * @param userId 当前登录用户ID,由服务端登录态取得 + * @param longitude 当前腾讯地图GCJ-02定位经度,范围为[-180,180],不能为NaN或无穷值 + * @param latitude 当前腾讯地图GCJ-02定位纬度,范围为[-90,90],与课程坐标相距不得超过配置的点位半径 + * @return code/msg/data结构;data含签到进度、动态表单、福利跳转及达标处理状态 + */ + NutMap handleScanCodeSign(String courseId, String userId, Double longitude, Double latitude); + + /** + * 保存品牌活动达标后的动态字段,同步至该用户在本活动已签到课程的报名记录。 + * + * @param courseId 当前扫码品牌课程ID,用于确定活动及统一的课程类型 + * @param userId 当前登录用户ID,由服务端登录态取得 + * @param mobileColumnsValue JSON数组,每项包含columnCode和columnValue,附件保留数组结构 + * @throws IllegalArgumentException 未达标、无参与权限或字段校验不通过时抛出业务提示 + */ + void saveScanCodeDynamicForm(String courseId, String userId, String mobileColumnsValue); + /** * 查询活动的报名条件 * @param activityId diff --git a/src/main/java/io/v/nutz/brandtrainactivity/service/BrandTrainSignUpActivityStatisticsService.java b/src/main/java/io/v/nutz/brandtrainactivity/service/BrandTrainSignUpActivityStatisticsService.java index 5e82514..95d7d5f 100644 --- a/src/main/java/io/v/nutz/brandtrainactivity/service/BrandTrainSignUpActivityStatisticsService.java +++ b/src/main/java/io/v/nutz/brandtrainactivity/service/BrandTrainSignUpActivityStatisticsService.java @@ -1,5 +1,7 @@ package io.v.nutz.brandtrainactivity.service; +import org.apache.poi.ss.usermodel.Workbook; + import cn.wizzer.framework.page.Pagination; import io.v.nutz.modules.models.PageForm; import io.v.nutz.service.ViService; @@ -55,4 +57,13 @@ public interface BrandTrainSignUpActivityStatisticsService extends ViService baoMingUserList(String activityId); + + /** + * 按活动、人员汇总签到后填写的信息,同一人员仅导出一行。 + * + * @param activityId 活动主键;活动内课程须使用同一种动态字段配置 + * @return XSSF 工作簿,包含人员信息、已签到课程及课程类型配置的动态字段 + */ + Workbook exportFillFormWorkbook(String activityId); + } diff --git a/src/main/java/io/v/nutz/brandtrainactivity/service/impl/BrandTrainSignUpActivityServiceImpl.java b/src/main/java/io/v/nutz/brandtrainactivity/service/impl/BrandTrainSignUpActivityServiceImpl.java index d2e61a1..c3ce09a 100644 --- a/src/main/java/io/v/nutz/brandtrainactivity/service/impl/BrandTrainSignUpActivityServiceImpl.java +++ b/src/main/java/io/v/nutz/brandtrainactivity/service/impl/BrandTrainSignUpActivityServiceImpl.java @@ -2,6 +2,7 @@ package io.v.nutz.brandtrainactivity.service.impl; import cn.hutool.core.util.StrUtil; import cn.wizzer.framework.page.Pagination; +import io.v.nutz.activity.models.ActivityUserScope; import io.v.nutz.base.utils.DateUtil; import io.v.nutz.base.utils.Vi; import io.v.nutz.brandtrainactivity.models.*; @@ -15,6 +16,9 @@ import io.v.nutz.modules.models.PageForm; import io.v.nutz.service.impl.ViServiceImpl; import io.v.nutz.sys.models.Sys_user; import io.v.nutz.web.commons.utils.ShiroUtil; +import io.v.nutz.welfare.model.WelfareList; +import io.v.nutz.welfare.model.WelfareProject; +import io.v.nutz.welfare.service.WelfareListService; import lombok.extern.slf4j.Slf4j; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Chain; @@ -29,13 +33,19 @@ import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Lang; import org.nutz.lang.Mirror; +import org.nutz.json.Json; import org.nutz.lang.util.NutMap; import java.lang.reflect.Field; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Calendar; import java.util.Date; import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Collections; +import java.util.Objects; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; @@ -43,6 +53,9 @@ import java.util.stream.Collectors; @IocBean(args = {"refer:dao"}) public class BrandTrainSignUpActivityServiceImpl extends ViServiceImpl implements BrandTrainSignUpActivityService { + // 历史课程未配置半径时沿用250米;扫码与GPS均以当前课程保存的半径为准。 + private static final int DEFAULT_SIGN_RADIUS = 250; + public BrandTrainSignUpActivityServiceImpl(Dao dao) { super(dao); } @@ -50,9 +63,13 @@ public class BrandTrainSignUpActivityServiceImpl extends ViServiceImpl courses = activity.getCourseList(); + // 扫码课程均须保存有效地点;校验不能放在扩展配置之后,避免未开启扩展时绕过坐标要求。 + if (courses != null) { + for (BrandTrainSignUpCourse course : courses) { + // 旧客户端和历史课程缺少半径时补默认值;显式非法值不得静默放行。 + if (course.getSignRadius() == null) { + course.setSignRadius(DEFAULT_SIGN_RADIUS); + } else if (course.getSignRadius() <= 0) { + throw new IllegalArgumentException("课程“" + course.getCourseName() + "”的点位半径必须为正整数(米)"); + } + if (course.isMobileSign() && Integer.valueOf(1).equals(course.getSignType()) + && !validScanCodeCoordinates(course.getCourseLocationCoordinates())) { + throw new IllegalArgumentException("请为课程“" + course.getCourseName() + "”设置有效的签到地点坐标"); + } + } + } + Integer pointCount = activity.getSignPointCount(); + Integer handleType = activity.getSignHandleType(); + if (pointCount == null || pointCount == 0) { + if ((handleType != null && handleType != 3) || StrUtil.isNotBlank(activity.getWelfareProjectId())) { + throw new IllegalArgumentException("请先配置签到达标点位数"); + } + activity.setSignHandleType(3); + activity.setWelfareProjectId(null); + return; + } + if (pointCount < 0 || courses == null || courses.isEmpty() || pointCount > courses.size()) { + throw new IllegalArgumentException("签到点位数必须为不超过课程数量的正整数"); + } + if (courses.stream().anyMatch(item -> !item.isMobileSign())) { + throw new IllegalArgumentException("全部课程开启手机签到后才能配置签到扩展"); + } + if (handleType == null || handleType < 1 || handleType > 3) { + throw new IllegalArgumentException("请选择正确的签到达标处理方式"); + } + if (handleType == 1 && (courses.stream().anyMatch(item -> StrUtil.isBlank(item.getCourseType())) + || courses.stream().map(BrandTrainSignUpCourse::getCourseType).distinct().count() != 1)) { + throw new IllegalArgumentException("填写信息方式要求全部课程使用同一种课程类型"); + } + if (handleType == 2) { + WelfareProject project = StrUtil.isBlank(activity.getWelfareProjectId()) ? null + : dao().fetch(WelfareProject.class, activity.getWelfareProjectId()); + if (project == null || project.getChoiceTimeEnd() == null || project.getChoiceTimeEnd().before(new Date())) { + throw new IllegalArgumentException("请选择选择时间未结束的福利项目"); + } + } else { + activity.setWelfareProjectId(null); + } + } + + /** 进入扫码页只读取已有进度,历史尚未设置坐标的课程仍可预览,由页面提示补齐配置。 */ + @Override + public NutMap getScanCodeSignData(String courseId, String userId) { + if (StrUtil.isBlank(userId) || !Objects.equals(userId, ShiroUtil.getPrincipalProperty("id"))) { + throw new IllegalArgumentException("未获取到当前登录用户"); + } + BrandTrainSignUpCourse course = requireScanCodeCourse(courseId, userId); + BrandTrainSignUpActivity activity = dao().fetch(BrandTrainSignUpActivity.class, course.getActivityId()); + return buildScanCodeData(activity, course, userId, false); + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public NutMap handleScanCodeSign(String courseId, String userId, Double longitude, Double latitude) { + lockScanCodeUser(userId); + BrandTrainSignUpCourse course = requireScanCodeCourse(courseId, userId); + // 地点配置和用户定位均在补报名、课次写入与福利处理之前校验,校验失败不产生业务数据。 + requireScanCodeLocation(course.getCourseLocationCoordinates(), longitude, latitude, getSignRadius(course)); + BrandTrainSignUpActivity activity = dao().fetch(BrandTrainSignUpActivity.class, course.getActivityId()); + ensureScanCodeSignUpUser(activity, course, userId); + List records = dao().query(BrandTrainSignUpUserCourse.class, + Cnd.where("activityId", "=", activity.getId()).and("courseId", "=", courseId) + .and("userId", "=", userId).asc("courseStartTime").asc("id")); + + // 达标按不同课程点位累计,同一课程的多节课或重复扫描不重复增加点位数。 + if (records.stream().anyMatch(BrandTrainSignUpUserCourse::isAttend)) { + return scanCodeResponse(99, "此点位,您已经签到", buildScanCodeData(activity, course, userId, true)); + } + List lessons = dao().query(BrandTrainSignUpActivityCourse.class, + Cnd.where("activityId", "=", activity.getId()).and("courseId", "=", courseId) + .asc("courseStartTime").asc("id")); + Date now = new Date(); + // 单课次点位沿用源功能的活动时间范围;多课次必须匹配当前课次,不能任取一条签到。 + List currentLessons = lessons.stream() + .filter(item -> item.getCourseStartTime() != null && item.getCourseEndTime() != null + && !now.before(item.getCourseStartTime()) && !now.after(item.getCourseEndTime())) + .collect(Collectors.toList()); + if (lessons.isEmpty() || lessons.size() > 1 && currentLessons.size() != 1) { + throw new IllegalArgumentException("当前点位没有唯一可签到的课次时间配置"); + } + BrandTrainSignUpActivityCourse lesson = lessons.size() == 1 ? lessons.get(0) : currentLessons.get(0); + BrandTrainSignUpUserCourse userCourse = records.stream() + .filter(item -> Objects.equals(item.getActivityCourseId(), lesson.getId())).findFirst().orElse(null); + if (userCourse == null) { + userCourse = new BrandTrainSignUpUserCourse(); + userCourse.setActivityId(activity.getId()); + userCourse.setCourseId(courseId); + userCourse.setUserId(userId); + userCourse.setActivityCourseId(lesson.getId()); + userCourse.setCourseStartTime(lesson.getCourseStartTime()); + userCourse.setCourseEndTime(lesson.getCourseEndTime()); + dao().insert(userCourse); + } + userCourse.setAttend(true); + userCourse.setAttendTime(now); + dao().update(userCourse); + if (Integer.valueOf(1).equals(activity.getSignHandleType())) { + syncAttendMobileColumnsValue(activity.getId(), userId, getLatestScanCodeForm(activity.getId(), userId)); + } + return scanCodeResponse(0, "签到成功", buildScanCodeData(activity, course, userId, true)); + } + + /** 坐标统一为[经度,纬度]两个有限数,排除缺失、越界及异常数字,避免距离比较被NaN绕过。 */ + private boolean validScanCodeCoordinates(List coordinates) { + return coordinates != null && coordinates.size() == 2 + && validScanCodePosition(coordinates.get(0), coordinates.get(1)); + } + + /** 腾讯地图GCJ-02坐标按经度[-180,180]、纬度[-90,90]校验,零值本身属于有效坐标。 */ + private boolean validScanCodePosition(Double longitude, Double latitude) { + return longitude != null && latitude != null && Double.isFinite(longitude) && Double.isFinite(latitude) + && longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90; + } + + /** course为数据库课程;返回生效半径(米),仅缺失配置使用历史默认值,非法配置禁止签到。 */ + private int getSignRadius(BrandTrainSignUpCourse course) { + Integer radius = course.getSignRadius(); + if (radius == null) { + return DEFAULT_SIGN_RADIUS; + } + if (radius <= 0) { + throw new IllegalArgumentException("当前点位半径配置无效,请联系管理员"); + } + return radius; + } + + /** 服务端按同坐标系经纬度计算球面距离;signRadius为数据库半径(米),边界以内允许签到。 */ + private void requireScanCodeLocation(List coordinates, Double longitude, Double latitude, int signRadius) { + if (!validScanCodeCoordinates(coordinates)) { + throw new IllegalArgumentException("当前点位未配置有效的签到地点坐标,请联系管理员"); + } + if (!validScanCodePosition(longitude, latitude)) { + throw new IllegalArgumentException("当前定位坐标无效,请重新获取定位后再签到"); + } + double currentLatitude = Math.toRadians(latitude); + double targetLatitude = Math.toRadians(coordinates.get(1)); + double latitudeDifference = targetLatitude - currentLatitude; + double longitudeDifference = Math.toRadians(coordinates.get(0) - longitude); + double haversine = Math.pow(Math.sin(latitudeDifference / 2), 2) + + Math.cos(currentLatitude) * Math.cos(targetLatitude) * Math.pow(Math.sin(longitudeDifference / 2), 2); + // 浮点计算误差可能使结果略超[0,1],夹紧后再求距离,保证远距离点也得到有效校验结果。 + double distance = 6371000D * 2 * Math.asin(Math.sqrt(Math.max(0, Math.min(1, haversine)))); + if (distance > signRadius) { + throw new IllegalArgumentException("请在签到点位" + signRadius + "米内签到"); + } + } + + @Override + @Aop(TransAop.READ_COMMITTED) + public void saveScanCodeDynamicForm(String courseId, String userId, String mobileColumnsValue) { + lockScanCodeUser(userId); + BrandTrainSignUpCourse course = requireScanCodeCourse(courseId, userId); + BrandTrainSignUpActivity activity = dao().fetch(BrandTrainSignUpActivity.class, course.getActivityId()); + if (!Integer.valueOf(1).equals(activity.getSignHandleType())) { + throw new IllegalArgumentException("当前活动无需填写动态表单"); + } + if (!buildScanCodeData(activity, course, userId, false).getBoolean("isReachTarget", false)) { + throw new IllegalArgumentException("当前活动尚未达标,暂不能填写动态表单"); + } + List formData; + try { + formData = Json.fromJsonAsList(NutMap.class, mobileColumnsValue); + } catch (Exception e) { + throw new IllegalArgumentException("填写信息格式不正确"); + } + List columns = getScanCodeColumns(course); + if (formData == null || columns.isEmpty()) { + throw new IllegalArgumentException("请填写当前活动配置的信息"); + } + Map inputMap = new LinkedHashMap<>(); + for (NutMap item : formData) { + if (item == null || StrUtil.isBlank(item.getString("columnCode")) + || inputMap.put(item.getString("columnCode"), item) != null) { + throw new IllegalArgumentException("填写信息包含无效或重复的字段"); + } + } + List columnCodes = columns.stream().map(BrandTrainMobileSignColumn::getColumnCode).collect(Collectors.toList()); + if (!columnCodes.containsAll(inputMap.keySet())) { + throw new IllegalArgumentException("填写信息包含未配置的字段"); + } + List normalizedData = new ArrayList<>(); + for (BrandTrainMobileSignColumn column : columns) { + Object value = inputMap.containsKey(column.getColumnCode()) ? inputMap.get(column.getColumnCode()).get("columnValue") : null; + value = validateScanCodeColumnValue(column, value); + normalizedData.add(NutMap.NEW().addv("columnCode", column.getColumnCode()) + .addv("columnName", column.getColumnName()).addv("columnFormType", column.getColumnFormType()) + .addv("columnValue", value)); + } + syncAttendMobileColumnsValue(activity.getId(), userId, normalizedData); + } + + /** + * 新入口在事务内锁定登录用户行,锁持有到事务提交,避免同用户并发扫码先查后插产生重复记录。 + * 不改变普通报名和普通签到路径,也不增加表、索引或数据库脚本。 + */ + private void lockScanCodeUser(String userId) { + if (StrUtil.isBlank(userId) || !Objects.equals(userId, ShiroUtil.getPrincipalProperty("id"))) { + throw new IllegalArgumentException("未获取到当前登录用户"); + } + NutMap user = fetch(Sqls.create("SELECT id FROM sys_user WHERE id = @userId FOR UPDATE").setParam("userId", userId)); + if (user == null || user.isEmpty()) { + throw new IllegalArgumentException("未找到当前用户信息"); + } + } + + /** 校验新点位入口的启用状态、活动时间及当前项目原有人员范围、条件和课程开放规则。 */ + private BrandTrainSignUpCourse requireScanCodeCourse(String courseId, String userId) { + BrandTrainSignUpCourse course = StrUtil.isBlank(courseId) ? null : dao().fetch(BrandTrainSignUpCourse.class, courseId); + if (course == null || !course.isMobileSign() || !Integer.valueOf(1).equals(course.getSignType())) { + throw new IllegalArgumentException("当前课程未开启扫码签到"); + } + BrandTrainSignUpActivity activity = dao().fetch(BrandTrainSignUpActivity.class, course.getActivityId()); + if (activity == null || activity.getSignPointCount() == null || activity.getSignPointCount() <= 0) { + throw new IllegalArgumentException("当前活动未开启签到扩展,请从原签到入口操作"); + } + Date now = new Date(); + if (activity.isDisabled() || activity.getActivityStartTime() == null || activity.getActivityEndTime() == null + || now.before(activity.getActivityStartTime()) || now.after(activity.getActivityEndTime())) { + throw new IllegalArgumentException("当前活动未开放或不在活动时间范围内"); + } + // 已有报名沿用原页面仅正常状态1/3可签到的限制,候补、废弃等状态不能经新入口达标或填表。 + // 没有报名记录时继续允许源点位扫码自动补报名,不将该规则扩展到原有报名状态。 + List signUpUsers = dao().query(BrandTrainSignUpUser.class, + Cnd.where("activityId", "=", activity.getId()).and("courseId", "=", courseId).and("userId", "=", userId)); + if (signUpUsers.stream().anyMatch(item -> !Integer.valueOf(1).equals(item.getState()) + && !Integer.valueOf(3).equals(item.getState()))) { + throw new IllegalArgumentException("当前报名状态不能签到或填写信息"); + } + if (activity.getActivityGroupId() != null && activity.getActivityGroupId() > 0 + && dao().count(ActivityUserScope.class, Cnd.where("groupId", "=", activity.getActivityGroupId()) + .and("userId", "=", userId)) == 0) { + throw new IllegalArgumentException("您没有权限参与此次活动"); + } + if ((StrUtil.isNotBlank(activity.getConditionStructureId()) + && !validConditionStructure(activity.getConditionStructureId()).getBoolean("flag", false)) + || (StrUtil.isNotBlank(course.getConditionStructureId()) + && !validConditionStructure(course.getConditionStructureId()).getBoolean("flag", false))) { + throw new IllegalArgumentException("您不满足当前活动或课程的参与条件"); + } + if (StrUtil.isNotBlank(course.getInterTime()) && Boolean.FALSE.equals(course.getOpenOtherUnion()) + && !Objects.equals(Vi.getUnionId(), course.getHostUnionId()) + && now.before(cn.hutool.core.date.DateUtil.parse(course.getInterTime()))) { + throw new IllegalArgumentException("当前课程暂未开放给其他工会"); + } + List courses = dao().query(BrandTrainSignUpCourse.class, Cnd.where("activityId", "=", activity.getId())); + if (courses.stream().anyMatch(item -> !item.isMobileSign()) || activity.getSignPointCount() > courses.size()) { + throw new IllegalArgumentException("当前活动签到扩展配置无效,请联系管理员"); + } + if (Integer.valueOf(1).equals(activity.getSignHandleType()) + && (courses.stream().anyMatch(item -> StrUtil.isBlank(item.getCourseType())) + || courses.stream().map(BrandTrainSignUpCourse::getCourseType).distinct().count() != 1)) { + throw new IllegalArgumentException("填写信息方式要求全部课程使用同一种课程类型"); + } + return course; + } + + /** + * 未报名的扫码用户须满足品牌原有总名额、分工会名额和类型/活动报名数量限制,候补不能直接签到。 + * 新入口锁定当前课程行后复用品牌报名方法,沿用品牌同步生成全部课次记录的原有行为。 + * @param activity 当前已校验开放状态的品牌活动 + * @param course 当前扫码品牌课程,不计入尚未填写的家属人数 + * @param userId 当前登录用户ID + */ + @Aop(TransAop.READ_COMMITTED) + private void ensureScanCodeSignUpUser(BrandTrainSignUpActivity activity, BrandTrainSignUpCourse course, String userId) { + if (dao().count(BrandTrainSignUpUser.class, Cnd.where("activityId", "=", activity.getId()) + .and("courseId", "=", course.getId()).and("userId", "=", userId)) > 0) { + return; + } + // 同课程不同用户的新扫码补报名串行校验名额,避免两个新入口同时占用最后一个正常名额。 + fetch(Sqls.create("SELECT id FROM brand_train_sign_up_course WHERE id = @courseId FOR UPDATE") + .setParam("courseId", course.getId())); + if (isSignFull(course.getId(), 0)) { + throw new IllegalArgumentException("当前报名人数已满"); + } + if (isSignFullByUnionId(course.getId(), 0)) { + throw new IllegalArgumentException("该活动您所在的分工会名额已报满"); + } + if (!isSignCourse(course.getId(), activity.getId())) { + throw new IllegalArgumentException("您已达到当前品牌活动或课程类型的报名数量上限"); + } + if (Integer.valueOf(2).equals(course.getReserveMode()) + && dao().count(BrandTrainSignUpUser.class, Cnd.where("courseId", "=", course.getId()).and("state", "!=", 2)) + >= course.getCoursePeopleNumber()) { + throw new IllegalArgumentException("当前仅剩候补名额,请先完成正常报名后再签到"); + } + BrandTrainSignUpUser signUpUser = new BrandTrainSignUpUser(); + signUpUser.setActivityId(activity.getId()); + signUpUser.setCourseId(course.getId()); + try { + doSignUp(signUpUser); + } catch (Exception e) { + throw new IllegalStateException("补齐品牌活动报名记录失败", e); + } + } + + /** + * 组装地图、当前点位状态、活动进度和表单回显;当前点位已签与整个活动达标分别计算。 + * 页面预览和表单保存均使用allowHandle=false,只能回显已有福利名单,不新增任何业务记录。 + */ + private NutMap buildScanCodeData(BrandTrainSignUpActivity activity, BrandTrainSignUpCourse course, String userId, boolean allowHandle) { + List attendRecords = dao().query(BrandTrainSignUpUserCourse.class, + Cnd.where("activityId", "=", activity.getId()).and("userId", "=", userId).and("isAttend", "=", true)); + long signCount = attendRecords.stream().map(BrandTrainSignUpUserCourse::getCourseId).filter(StrUtil::isNotBlank).distinct().count(); + boolean reached = signCount >= activity.getSignPointCount(); + Integer handleType = activity.getSignHandleType() == null ? 3 : activity.getSignHandleType(); + Map values = new LinkedHashMap<>(); + getLatestScanCodeForm(activity.getId(), userId).forEach(item -> values.put(item.getString("columnCode"), item.get("columnValue"))); + List dynamicColumns = new ArrayList<>(); + for (BrandTrainMobileSignColumn column : getScanCodeColumns(course)) { + NutMap columnMap = Lang.obj2nutmap(column); + columnMap.put("columnValue", values.containsKey(column.getColumnCode()) ? values.get(column.getColumnCode()) + : "FILE".equals(column.getColumnFormType()) ? new ArrayList<>() : ""); + dynamicColumns.add(columnMap); + } + NutMap data = NutMap.NEW().addv("activityName", activity.getActivityName()).addv("courseName", course.getCourseName()) + .addv("courseLocation", course.getCourseLocation()).addv("courseLocationCoordinates", course.getCourseLocationCoordinates()) + .addv("currentPointSigned", attendRecords.stream().anyMatch(item -> Objects.equals(item.getCourseId(), course.getId()))) + .addv("signRadius", getSignRadius(course)) + .addv("signCount", signCount).addv("signPointCount", activity.getSignPointCount()) + .addv("signHandleType", handleType).addv("welfareProjectId", activity.getWelfareProjectId()) + .addv("isReachTarget", reached).addv("dynamicColumnsData", dynamicColumns) + .addv("needDynamicForm", reached && handleType == 1 && !dynamicColumns.isEmpty()) + .addv("needWelfareSelect", false).addv("welfareJumpUrl", "") + .addv("handleStatus", "NONE").addv("handleMsg", reached ? "当前活动已达标" : "请继续完成其他点位签到"); + if (reached && handleType == 1 && !dynamicColumns.isEmpty()) { + // 达标处理覆盖默认状态和提示;addv 会将同名字段累加为数组,导致手机端直接显示数组内容。 + data.setv("handleStatus", "PENDING_FORM").setv("handleMsg", "请点击下方按钮填写详细信息。"); + } else if (reached && handleType == 2) { + if (allowHandle) { + completeScanCodeWelfare(activity.getWelfareProjectId(), userId, data); + } else if (dao().count(WelfareList.class, Cnd.where("projectId", "=", activity.getWelfareProjectId()) + .and("userId", "=", userId)) > 0) { + // 已达标用户刷新后保留已有福利入口,只读查询不能为尚未入名单的用户补建福利记录。 + buildScanCodeWelfareData(activity.getWelfareProjectId(), data); + } + } + return data; + } + + /** 已达标用户复用本项目福利名单服务,并由现有福利选择页处理时间、选项和签收。 */ + @Aop(TransAop.READ_COMMITTED) + private void completeScanCodeWelfare(String projectId, String userId, NutMap data) { + buildScanCodeWelfareData(projectId, data); + if (!data.getBoolean("needWelfareSelect", false)) { + return; + } + if (dao().count(WelfareList.class, Cnd.where("projectId", "=", projectId).and("userId", "=", userId)) == 0) { + welfareListService.joinWelfareList(projectId, Collections.singletonList(userId)); + } + } + + /** 福利入口组装仅校验项目并设置返回字段,供手动签到和已有名单的只读预览共同使用。 */ + private void buildScanCodeWelfareData(String projectId, NutMap data) { + WelfareProject project = StrUtil.isBlank(projectId) ? null : dao().fetch(WelfareProject.class, projectId); + if (project == null || project.getChoiceTimeEnd() == null || project.getChoiceTimeEnd().before(new Date())) { + // 福利校验失败时覆盖默认结果,确保状态和提示仍为单个字符串。 + data.setv("handleStatus", "FAILED").setv("handleMsg", "关联福利项目不存在或选择时间已结束,请联系管理员"); + return; + } + // 覆盖默认福利结果,保持选择标记为布尔值、跳转地址为字符串,避免数组转换后生成错误地址。 + data.setv("handleStatus", "PENDING_WELFARE").setv("handleMsg", "当前活动已达标,请前往选择福利") + .setv("needWelfareSelect", true).setv("welfareJumpUrl", "/mobile/welfare/list/receive?projectId=" + projectId); + } + + /** 动态字段沿用当前课程类型配置,排序与既有报名表单保持一致。 */ + private List getScanCodeColumns(BrandTrainSignUpCourse course) { + if (StrUtil.isBlank(course.getCourseType())) { + return new ArrayList<>(); + } + return dao().query(BrandTrainMobileSignColumn.class, Cnd.where("typeId", "=", course.getCourseType()).asc("columnIndex")); + } + + /** 新点位继续使用活动内最近一份已保存表单,按报名时间和ID排序保证回显结果稳定。 */ + private List getLatestScanCodeForm(String activityId, String userId) { + List users = dao().query(BrandTrainSignUpUser.class, + Cnd.where("activityId", "=", activityId).and("userId", "=", userId).desc("signUpTime").desc("id")); + return users.stream().map(BrandTrainSignUpUser::getMobileColumnsValue).filter(item -> item != null && !item.isEmpty()) + .findFirst().orElseGet(ArrayList::new); + } + + /** 在上层扫码或表单保存事务内同步已签到课程,未签到的普通报名记录保持原值。 */ + @Aop(TransAop.READ_COMMITTED) + private void syncAttendMobileColumnsValue(String activityId, String userId, List formData) { + if (formData == null || formData.isEmpty()) { + return; + } + List attends = dao().query(BrandTrainSignUpUserCourse.class, + Cnd.where("activityId", "=", activityId).and("userId", "=", userId).and("isAttend", "=", true)); + List courseIds = attends.stream().map(BrandTrainSignUpUserCourse::getCourseId).distinct().collect(Collectors.toList()); + if (courseIds.isEmpty()) { + return; + } + List users = dao().query(BrandTrainSignUpUser.class, + Cnd.where("activityId", "=", activityId).and("userId", "=", userId).and("courseId", "in", courseIds)); + users.forEach(item -> item.setMobileColumnsValue(Json.fromJsonAsList(NutMap.class, Json.toJson(formData)))); + dao().update(users); + } + + /** 字段定义以服务端配置为准,校验必填、整数/小数、日期、选项和附件数组,保留附件原有结构。 */ + private Object validateScanCodeColumnValue(BrandTrainMobileSignColumn column, Object value) { + boolean empty = value == null || value instanceof String && StrUtil.isBlank((String) value) + || value instanceof List && ((List) value).isEmpty(); + if (empty) { + if (Boolean.TRUE.equals(column.getIsRequired())) { + throw new IllegalArgumentException("请填写" + column.getColumnName()); + } + return "FILE".equals(column.getColumnFormType()) ? new ArrayList<>() : ""; + } + String text = String.valueOf(value); + String type = column.getColumnType(); + try { + if (!"JSON".equals(type) && !"MYSQL_JSON".equals(type) && (value instanceof List || value instanceof Map)) { + throw new IllegalArgumentException(); + } + if ("INT".equals(type) || "INT2".equals(type) || "INT4".equals(type) || "INT8".equals(type)) { + new BigDecimal(text).toBigIntegerExact(); + } else if ("FLOAT".equals(type) || "DOUBLE".equals(type) || "DECIMAL".equals(type)) { + new BigDecimal(text); + } else if ("DATE".equals(type)) { + java.time.LocalDate.parse(text); + } else if ("DATETIME".equals(type) || "TIMESTAMP".equals(type)) { + java.time.LocalDateTime.parse(text, java.time.format.DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss") + .withResolverStyle(java.time.format.ResolverStyle.STRICT)); + } else if ("BOOLEAN".equals(type) && !"true".equalsIgnoreCase(text) && !"false".equalsIgnoreCase(text)) { + throw new IllegalArgumentException(); + } else if ("JSON".equals(type) || "MYSQL_JSON".equals(type)) { + value = value instanceof String ? Json.fromJson(text) : value; + if (!(value instanceof List) && !(value instanceof Map)) { + throw new IllegalArgumentException(); + } + } + } catch (Exception e) { + throw new IllegalArgumentException(column.getColumnName() + "格式不正确"); + } + if ("SELECT".equals(column.getColumnFormType()) && !"DATE".equals(type) && !"DATETIME".equals(type) + && (column.getSelectValues() == null || !column.getSelectValues().contains(text))) { + throw new IllegalArgumentException("请选择有效的" + column.getColumnName()); + } + if ("FILE".equals(column.getColumnFormType()) && (!(value instanceof List) + || column.getFileNumber() != null && ((List) value).size() > column.getFileNumber() + || Boolean.TRUE.equals(column.getIsRequired()) && ((List) value).isEmpty())) { + throw new IllegalArgumentException(column.getColumnName() + "附件数量不正确"); + } + return value; + } + + /** 扫码接口保持code/msg/data结构,重复签到也返回进度供手机端继续办理达标事项。 */ + private NutMap scanCodeResponse(int code, String msg, NutMap data) { + return NutMap.NEW().addv("code", code).addv("msg", msg).addv("data", data); + } + private Boolean checkCnd(MatchConditionStructure cnd) { return cnd != null && cnd.getConditions() != null && cnd.getMethod() != null && cnd.getConditions().stream().allMatch(v -> v.getField() != null && v.getOperational() != null && v.getValue() != null); } diff --git a/src/main/java/io/v/nutz/brandtrainactivity/service/impl/BrandTrainSignUpActivityStatisticsServiceImpl.java b/src/main/java/io/v/nutz/brandtrainactivity/service/impl/BrandTrainSignUpActivityStatisticsServiceImpl.java index 3c865f6..437db98 100644 --- a/src/main/java/io/v/nutz/brandtrainactivity/service/impl/BrandTrainSignUpActivityStatisticsServiceImpl.java +++ b/src/main/java/io/v/nutz/brandtrainactivity/service/impl/BrandTrainSignUpActivityStatisticsServiceImpl.java @@ -1,5 +1,20 @@ package io.v.nutz.brandtrainactivity.service.impl; +import cn.afterturn.easypoi.excel.ExcelExportUtil; +import cn.afterturn.easypoi.excel.entity.ExportParams; +import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; +import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity; +import cn.hutool.core.util.StrUtil; +import io.v.nutz.brandtrainactivity.models.BrandTrainMobileSignColumn; +import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpActivity; +import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpCourse; +import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpType; +import org.apache.poi.ss.usermodel.Workbook; +import org.nutz.dao.Cnd; +import org.nutz.json.Json; +import java.util.ArrayList; +import java.util.HashMap; + import cn.wizzer.framework.page.Pagination; import io.v.nutz.base.utils.Vi; import io.v.nutz.dao.CndPlus; @@ -175,4 +190,113 @@ public class BrandTrainSignUpActivityStatisticsServiceImpl extends ViServiceImpl sql.setParam("activityId", activityId); return listMap(sql); } + + /** + * 按活动汇总人员填写的信息;同一人员报名多个点位时只输出一行。 + * + * @param activityId 需要导出的品牌活动ID,不可为空 + * @return XSSF工作簿;固定列为人员信息和已签到课程,后续列为该活动课程类型的动态字段 + */ + @Override + public Workbook exportFillFormWorkbook(String activityId) { + if (StrUtil.isBlank(activityId)) { + throw new IllegalArgumentException("请选择需要导出的活动"); + } + BrandTrainSignUpActivity activity = dao().fetch(BrandTrainSignUpActivity.class, activityId); + if (activity == null) { + throw new IllegalArgumentException("未找到当前活动"); + } + List courseList = dao().query(BrandTrainSignUpCourse.class, + Cnd.where("activityId", "=", activityId).asc("orderNum")); + List courseTypes = courseList.stream().map(BrandTrainSignUpCourse::getCourseType) + .filter(StrUtil::isNotBlank).distinct().collect(Collectors.toList()); + // 多种课程类型可能复用字段编码但含义不同,不能按同一份动态表头合并导出。 + if (courseTypes.size() > 1) { + throw new IllegalArgumentException("当前活动存在多个课程类型,不能导出填写信息"); + } + List columns = new ArrayList<>(); + if (!courseTypes.isEmpty()) { + BrandTrainSignUpType type = dao().fetch(BrandTrainSignUpType.class, courseTypes.get(0)); + if (type != null) { + dao().fetchLinks(type, "trainMobileSignColumnList", Cnd.NEW().asc("columnIndex")); + if (type.getTrainMobileSignColumnList() != null) { + columns = type.getTrainMobileSignColumnList(); + } + } + } + + Sql userSql = Sqls.create(""" + SELECT ts.userId, u.username, u.loginname, u.unitname, u.unionname, u.sex, + ts.mobileColumnsValue, ts.signUpTime + FROM brand_train_sign_up_user ts + LEFT JOIN `user` u ON u.id = ts.userId + WHERE ts.activityId = @activityId + ORDER BY ts.signUpTime DESC, ts.id + """).setParam("activityId", activityId); + Map> users = listMap(userSql).stream() + .filter(item -> StrUtil.isNotBlank(item.getString("userId"))) + .collect(Collectors.groupingBy(item -> item.getString("userId"), + LinkedHashMap::new, Collectors.toList())); + + Sql signSql = Sqls.create(""" + SELECT uc.userId, + GROUP_CONCAT(DISTINCT c.courseName ORDER BY c.orderNum SEPARATOR '、') AS signCourseNames + FROM brand_train_sign_up_user_course uc + LEFT JOIN brand_train_sign_up_course c ON c.id = uc.courseId + WHERE uc.activityId = @activityId AND uc.isAttend = 1 + GROUP BY uc.userId + """).setParam("activityId", activityId); + Map signCourseNames = listMap(signSql).stream() + .collect(Collectors.toMap(item -> item.getString("userId"), + item -> item.getString("signCourseNames", ""), (first, second) -> first)); + + List rows = new ArrayList<>(); + for (Map.Entry> entry : users.entrySet()) { + NutMap user = entry.getValue().get(0); + NutMap row = NutMap.NEW().addv("username", user.getString("username")) + .addv("loginname", user.getString("loginname")) + .addv("unitname", user.getString("unitname")) + .addv("unionname", user.getString("unionname")) + .addv("sex", user.getString("sex")) + .addv("signCourseNames", signCourseNames.getOrDefault(entry.getKey(), "")); + Map formValues = new HashMap<>(); + // 签到表单会同步到各已签到点位;历史记录未同步时使用最近一份非空表单。 + for (NutMap record : entry.getValue()) { + String json = record.getString("mobileColumnsValue"); + if (StrUtil.isBlank(json)) { + continue; + } + List values = Json.fromJsonAsList(NutMap.class, json); + if (values == null || values.isEmpty()) { + continue; + } + for (NutMap value : values) { + formValues.put(value.getString("columnCode"), value.get("columnValue")); + } + break; + } + for (BrandTrainMobileSignColumn column : columns) { + Object value = formValues.get(column.getColumnCode()); + // 动态字段单独命名,防止配置的字段编码覆盖姓名等固定导出列。 + row.addv("form_" + column.getColumnCode(), value == null ? "" : String.valueOf(value)); + } + rows.add(row); + } + + List entities = new ArrayList<>(); + entities.add(new ExcelExportEntity("姓名", "username", 20)); + entities.add(new ExcelExportEntity("工号", "loginname", 20)); + entities.add(new ExcelExportEntity("单位", "unitname", 30)); + entities.add(new ExcelExportEntity("分工会", "unionname", 30)); + entities.add(new ExcelExportEntity("性别", "sex", 10)); + entities.add(new ExcelExportEntity("已签到课程", "signCourseNames", 40)); + for (BrandTrainMobileSignColumn column : columns) { + entities.add(new ExcelExportEntity(column.getColumnName(), "form_" + column.getColumnCode(), 25)); + } + ExportParams exportParams = new ExportParams(); + exportParams.setType(ExcelType.XSSF); + exportParams.setTitle(activity.getActivityName() + "填写信息"); + exportParams.setSheetName("填写信息"); + return ExcelExportUtil.exportExcel(exportParams, entities, rows); + } } diff --git a/src/main/java/io/v/nutz/sys/services/SysUserService.java b/src/main/java/io/v/nutz/sys/services/SysUserService.java index cf8a39d..3485355 100644 --- a/src/main/java/io/v/nutz/sys/services/SysUserService.java +++ b/src/main/java/io/v/nutz/sys/services/SysUserService.java @@ -4,6 +4,7 @@ package io.v.nutz.sys.services; import io.v.nutz.base.service.BaseService; import io.v.nutz.sys.models.Sys_menu; import io.v.nutz.sys.models.Sys_user; +import org.nutz.lang.util.NutMap; import java.util.List; @@ -11,6 +12,15 @@ import java.util.List; * Created by wizzer on 2016/12/22. */ public interface SysUserService extends BaseService { + /** + * 无参读取当前Shiro Subject,为H5布局返回可公开的登录用户视图,不缓存当前用户。 + * + * @return 非null的NutMap:id/loginname/username/avatar/unitid/unionid为身份字符串, + * roles为仅含code的对象数组,permissions为权限字符串数组;匿名、未知Principal或禁用用户 + * 返回空身份字符串及两个空数组,不返回密码、盐、完整角色或用户实体。 + */ + NutMap getH5LogonUser(); + /** * 查询用户的角色 * diff --git a/src/main/java/io/v/nutz/sys/services/impl/SysUserServiceImpl.java b/src/main/java/io/v/nutz/sys/services/impl/SysUserServiceImpl.java index 4161edd..eb8f888 100644 --- a/src/main/java/io/v/nutz/sys/services/impl/SysUserServiceImpl.java +++ b/src/main/java/io/v/nutz/sys/services/impl/SysUserServiceImpl.java @@ -7,6 +7,9 @@ import io.v.nutz.sys.models.Sys_role; import io.v.nutz.sys.models.Sys_user; import io.v.nutz.sys.services.SysMenuService; import io.v.nutz.sys.services.SysUserService; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.UnavailableSecurityManagerException; +import org.apache.shiro.subject.Subject; import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.dao.Cnd; import org.nutz.dao.Dao; @@ -16,6 +19,7 @@ import org.nutz.ioc.aop.Aop; import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.lang.Strings; +import org.nutz.lang.util.NutMap; import org.nutz.plugins.wkcache.annotation.CacheDefaults; import org.nutz.plugins.wkcache.annotation.CacheRemove; import org.nutz.plugins.wkcache.annotation.CacheRemoveAll; @@ -23,6 +27,7 @@ import org.nutz.plugins.wkcache.annotation.CacheResult; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -39,6 +44,60 @@ public class SysUserServiceImpl extends BaseServiceImpl implements Sys @Inject private SysMenuService sysMenuService; + /** + * 无参从当前Shiro登录态读取用户,仅在H5模板实际调用时组装安全视图。 + * 返回身份白名单、roles:[{code}]和permissions:[字符串];空登录态、未知Principal、 + * 无有效用户ID或禁用用户均返回空身份和空数组,避免模板json(null)生成无效脚本。 + * 角色及菜单权限复用现有查询的禁用过滤规则,用户原始字符串由模板注入点负责脚本转义。 + * + * @return 每次调用独立创建的NutMap,不包含密码、盐或完整角色/用户关联对象 + */ + @Override + public NutMap getH5LogonUser() { + List roles = new ArrayList<>(); + List permissions = new ArrayList<>(); + NutMap result = NutMap.NEW().addv("id", "").addv("loginname", "").addv("username", "") + .addv("avatar", "").addv("unitid", "").addv("unionid", "") + .addv("roles", roles).addv("permissions", permissions); + Subject subject; + try { + subject = SecurityUtils.getSubject(); + } catch (UnavailableSecurityManagerException e) { + return result; + } + Object principal = subject == null ? null : subject.getPrincipal(); + if (!(principal instanceof Sys_user)) { + return result; + } + Sys_user user = (Sys_user) principal; + if (user.isDisabled() || Strings.isBlank(user.getId())) { + return result; + } + result.setv("id", user.getId()).setv("loginname", Strings.sNull(user.getLoginname())) + .setv("username", Strings.sNull(user.getUsername())).setv("avatar", Strings.sNull(user.getAvatar())) + .setv("unitid", Strings.sNull(user.getUnitid())).setv("unionid", Strings.sNull(user.getUnionid())); + List roleCodes = getRoleCodeList(user); + if (roleCodes != null) { + LinkedHashSet uniqueRoles = new LinkedHashSet<>(roleCodes); + for (String roleCode : uniqueRoles) { + if (!Strings.isBlank(roleCode)) { + roles.add(NutMap.NEW().addv("code", roleCode)); + } + } + } + List menus = getMenusAndButtons(user.getId()); + if (menus != null) { + LinkedHashSet uniquePermissions = new LinkedHashSet<>(); + for (Sys_menu menu : menus) { + if (menu != null && !menu.isDisabled() && !Strings.isBlank(menu.getPermission())) { + uniquePermissions.add(menu.getPermission()); + } + } + permissions.addAll(uniquePermissions); + } + return result; + } + /** * 查询用户角色code列表 * diff --git a/src/main/java/io/v/nutz/web/commons/controller/TMapController.java b/src/main/java/io/v/nutz/web/commons/controller/TMapController.java new file mode 100644 index 0000000..c4c33e3 --- /dev/null +++ b/src/main/java/io/v/nutz/web/commons/controller/TMapController.java @@ -0,0 +1,51 @@ +package io.v.nutz.web.commons.controller; + +import cn.hutool.core.util.StrUtil; +import io.v.nutz.annontation.ViReturn; +import io.v.nutz.base.result.Result; +import io.v.nutz.web.commons.ext.map.TMapService; +import org.apache.shiro.authz.annotation.RequiresAuthentication; +import org.nutz.ioc.loader.annotation.Inject; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.mvc.annotation.At; +import org.nutz.mvc.annotation.Ok; +import org.nutz.mvc.annotation.POST; +import org.nutz.mvc.annotation.Param; + +/** + * 腾讯地图公共接口,供已登录用户在各业务模块中搜索地点。 + */ +@IocBean +@At("/platform/TMap") +@Ok("json:full") +public class TMapController { + + @Inject + private TMapService tMapService; + + /** + * 查询腾讯地图地址候选,仅接收关键词;地图Key及搜索区域由公共服务读取配置。 + * + * @param keyword 地址关键词,去除首尾空白后须为1至100个字符 + * @return Result的code/msg/data;成功时data含腾讯status、message和地点data数组, + * 地点包含title、address、location.lat(纬度)和location.lng(经度) + */ + @At + @POST + @ViReturn + @RequiresAuthentication + public Result suggestion(@Param(value = "keyword", required = false) String keyword) { + if (StrUtil.isBlank(keyword)) { + return Result.error("请输入地址关键词"); + } + String searchKeyword = keyword.trim(); + if (searchKeyword.length() > 100) { + return Result.error("地址关键词不能超过100个字符"); + } + try { + return Result.success(tMapService.suggestion(searchKeyword)); + } catch (IllegalArgumentException e) { + return Result.error(e.getMessage()); + } + } +} diff --git a/src/main/java/io/v/nutz/web/commons/ext/map/TMapService.java b/src/main/java/io/v/nutz/web/commons/ext/map/TMapService.java new file mode 100644 index 0000000..d88e6fa --- /dev/null +++ b/src/main/java/io/v/nutz/web/commons/ext/map/TMapService.java @@ -0,0 +1,78 @@ +package io.v.nutz.web.commons.ext.map; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpUtil; +import io.v.nutz.web.commons.base.Globals; +import org.nutz.ioc.loader.annotation.IocBean; +import org.nutz.json.Json; +import org.nutz.lang.util.NutMap; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 腾讯地图公共地点搜索服务,负责请求参数编码、服务调用和响应校验。 + */ +@IocBean +public class TMapService { + + // 地点搜索仅访问腾讯固定服务;系统TencentMapKey可覆盖参考工程的默认Key。 + private static final String TENCENT_MAP_SUGGESTION_URL = "https://apis.map.qq.com/ws/place/v1/suggestion"; + private static final String DEFAULT_TENCENT_MAP_KEY = "L5WBZ-3DDKH-KW2DC-WCDWL-Y3SUV-WNBDI"; + + /** + * 读取腾讯地图地址候选,仅访问固定的地点搜索服务,不修改本地数据。 + * + * @param keyword 地址关键词,去除首尾空白后须为1至100个字符,搜索区域沿用系统CityName配置 + * @return 腾讯响应对象;status=0表示成功,data为地点数组,location.lat为纬度、location.lng为经度 + * @throws IllegalArgumentException 参数、网络或腾讯地图响应校验失败时提供可读业务提示 + */ + public NutMap suggestion(String keyword) { + if (StrUtil.isBlank(keyword) || keyword.trim().length() > 100) { + throw new IllegalArgumentException("地址关键词须为1至100个字符"); + } + String mapKey = Globals.MyConfig.getString("TencentMapKey"); + if (StrUtil.isBlank(mapKey)) { + mapKey = DEFAULT_TENCENT_MAP_KEY; + } + Map params = new LinkedHashMap<>(); + params.put("key", mapKey.trim()); + params.put("keyword", keyword.trim()); + params.put("region", StrUtil.trimToEmpty(Globals.MyConfig.getString("CityName"))); + // 参数交由HTTP库编码,避免中文、&等字符改变查询内容;禁用重定向,保持固定服务地址。 + String responseBody; + try (HttpResponse response = HttpUtil.createGet(TENCENT_MAP_SUGGESTION_URL) + .charset(StandardCharsets.UTF_8).form(params).timeout(5000).setFollowRedirects(false).execute()) { + if (response.getStatus() != 200) { + throw new IllegalStateException("腾讯地图地址搜索请求未成功"); + } + responseBody = response.body(); + } catch (Exception e) { + // HTTP库异常可能携带完整URL,禁止向前端或日志透传,以免暴露地图Key。 + throw new IllegalArgumentException("腾讯地图地址搜索失败,请稍后重试"); + } + if (StrUtil.isBlank(responseBody)) { + throw new IllegalArgumentException("腾讯地图地址搜索响应为空,请稍后重试"); + } + NutMap result; + try { + result = Json.fromJson(NutMap.class, responseBody); + } catch (Exception e) { + throw new IllegalArgumentException("腾讯地图地址搜索响应异常,请稍后重试"); + } + // 缺少状态不能按默认0当作成功;地址候选必须为数组,供前端自动完成组件直接使用。 + if (result == null || !(result.get("status") instanceof Number)) { + throw new IllegalArgumentException("腾讯地图地址搜索响应异常,请稍后重试"); + } + if (result.getInt("status") != 0) { + throw new IllegalArgumentException("腾讯地图地址搜索失败,请检查地图服务配置"); + } + if (!(result.get("data") instanceof List)) { + throw new IllegalArgumentException("腾讯地图地址搜索响应异常,请稍后重试"); + } + return result; + } +} diff --git a/src/main/java/io/v/nutz/web/commons/proc/GlobalsSettingProcessor.java b/src/main/java/io/v/nutz/web/commons/proc/GlobalsSettingProcessor.java index 01002dd..a3a32d2 100644 --- a/src/main/java/io/v/nutz/web/commons/proc/GlobalsSettingProcessor.java +++ b/src/main/java/io/v/nutz/web/commons/proc/GlobalsSettingProcessor.java @@ -2,6 +2,7 @@ package io.v.nutz.web.commons.proc; import io.v.nutz.base.utils.DateUtil; import io.v.nutz.base.utils.StringUtil; +import io.v.nutz.sys.services.SysUserService; import io.v.nutz.util.ShiroUtil; import io.v.nutz.web.commons.base.Globals; import org.nutz.lang.Strings; @@ -18,6 +19,7 @@ public class GlobalsSettingProcessor extends AbstractProcessor { private static ShiroUtil ShiroUtil; private static DateUtil dateUtil; private static StringUtil stringUtil; + private SysUserService h5UserService; public void init(NutConfig config, ActionInfo ai) throws Throwable { if (ShiroUtil == null) { @@ -29,6 +31,8 @@ public class GlobalsSettingProcessor extends AbstractProcessor { if (stringUtil == null) { stringUtil = new StringUtil(); } + // 仅取得当前应用的服务代理,不在处理器初始化时读取登录态或查询用户权限。 + h5UserService = config.getIoc().getByType(SysUserService.class); } @SuppressWarnings("rawtypes") @@ -41,6 +45,8 @@ public class GlobalsSettingProcessor extends AbstractProcessor { ac.getRequest().setAttribute("AppFileDomain", Globals.AppFileDomain); ac.getRequest().setAttribute("config", Globals.MyConfig); ac.getRequest().setAttribute("shiro", ShiroUtil); + // H5 布局渲染时再调用服务生成安全用户视图,其余请求只绑定对象,不额外查询用户。 + ac.getRequest().setAttribute("h5UserService", h5UserService); ac.getRequest().setAttribute("date", dateUtil); ac.getRequest().setAttribute("string", stringUtil); // 如果url中有语言属性则设置 diff --git a/src/main/java/io/v/nutz/welfare/service/WelfareProjectService.java b/src/main/java/io/v/nutz/welfare/service/WelfareProjectService.java index c5d7115..d20d16e 100644 --- a/src/main/java/io/v/nutz/welfare/service/WelfareProjectService.java +++ b/src/main/java/io/v/nutz/welfare/service/WelfareProjectService.java @@ -25,4 +25,11 @@ public interface WelfareProjectService extends ViService { List getSchoolWelfareList(Integer year); + /** + * 获取签到扩展可关联的福利项目,包含选择尚未开始和正在进行的项目。 + * + * @return 选择结束时间未过期的项目列表;每项的 id、name 用于活动配置的福利选择框 + */ + List getAvailableSignWelfareProjects(); + } diff --git a/src/main/java/io/v/nutz/welfare/service/impl/WelfareProjectServiceImpl.java b/src/main/java/io/v/nutz/welfare/service/impl/WelfareProjectServiceImpl.java index 5e3f9ae..2557541 100644 --- a/src/main/java/io/v/nutz/welfare/service/impl/WelfareProjectServiceImpl.java +++ b/src/main/java/io/v/nutz/welfare/service/impl/WelfareProjectServiceImpl.java @@ -18,6 +18,7 @@ import org.nutz.lang.Strings; import org.nutz.lang.util.NutMap; import org.nutz.plugins.wkcache.annotation.CacheDefaults; +import java.util.Date; import java.util.List; import java.util.stream.Collectors; @@ -76,4 +77,15 @@ public class WelfareProjectServiceImpl extends ViServiceImpl imp return projectList; } + + /** + * 当前项目没有福利禁用字段,按已有的选择结束时间筛选可关联项目。 + * + * @return 选择时间未结束的福利项目,按开始时间及创建时间排列 + */ + @Override + public List getAvailableSignWelfareProjects() { + return query(Cnd.where("choiceTimeEnd", ">=", new Date()) + .asc("choiceTimeStart").desc("createdAt")); + } } diff --git a/src/main/resources/static/components/module/brandTrainSignUp/BrandTrainDynamicForm.vue b/src/main/resources/static/components/module/brandTrainSignUp/BrandTrainDynamicForm.vue new file mode 100644 index 0000000..0437b71 --- /dev/null +++ b/src/main/resources/static/components/module/brandTrainSignUp/BrandTrainDynamicForm.vue @@ -0,0 +1,634 @@ + + + + + + + + diff --git a/src/main/resources/views/layouts/platform.html b/src/main/resources/views/layouts/platform.html index 9ccf006..aaf730c 100644 --- a/src/main/resources/views/layouts/platform.html +++ b/src/main/resources/views/layouts/platform.html @@ -70,6 +70,8 @@ + + diff --git a/src/main/resources/views/layouts/platform_h5.html b/src/main/resources/views/layouts/platform_h5.html index 47fdc70..f580075 100644 --- a/src/main/resources/views/layouts/platform_h5.html +++ b/src/main/resources/views/layouts/platform_h5.html @@ -226,7 +226,17 @@ + + diff --git a/src/main/resources/views/mobile/brandTrain/trainInfo.html b/src/main/resources/views/mobile/brandTrain/trainInfo.html index 13d5924..9963be5 100644 --- a/src/main/resources/views/mobile/brandTrain/trainInfo.html +++ b/src/main/resources/views/mobile/brandTrain/trainInfo.html @@ -285,6 +285,7 @@ layout("/mobile/platform.html"){ 未签到 { - if (res) { - const course = JSON.parse(res) - const resp = await $.post('/platform/mobile/brandTrainSignUpActivity/codeSign', { - codeCourseId: course.courseId, - clickCourseId: o.courseId, - id: o.id, - }) - if(resp.code === 0) { - vant.Dialog.alert({message: '签到成功'}) - await this.pageData() - o.isAttend = true - } else { - vant.Dialog.alert({message: resp.msg}) - } + if (this.formLoading) { + return + } + // 新点位二维码为本站签到页地址;旧课程二维码仍为 {courseId} JSON,并沿用原签到接口。 + wxScannerQrCode.scanner().then((res) => { + if (!res) { + return } - }).catch(err => { - vant.Dialog.alert({message: err}) + const qrContent = String(res).trim() + let course + try { + if (qrContent.charAt(0) === '{') { + course = JSON.parse(qrContent) + } else { + const signUrl = new URL(qrContent, window.location.origin) + const courseId = signUrl.searchParams.get('courseId') + // 只接受本站品牌活动签到页,且课程必须与当前点击的签到记录一致,避免任意地址跳转。 + if (signUrl.origin !== window.location.origin || signUrl.pathname !== '/platform/mobile/brandTrain/scanCode') { + vant.Dialog.alert({message: '请扫描当前品牌活动课程的签到二维码'}) + return + } + if (!courseId || courseId !== o.courseId) { + vant.Dialog.alert({message: '签到失败,二维码与您当前签到信息不符'}) + return + } + window.location.href = signUrl.origin + signUrl.pathname + '?courseId=' + encodeURIComponent(courseId) + return + } + } catch (err) { + vant.Dialog.alert({message: '二维码格式不正确,请重新扫描'}) + return + } + if (!course || !course.courseId || course.courseId !== o.courseId) { + vant.Dialog.alert({message: '签到失败,二维码与您当前签到信息不符'}) + return + } + // 旧二维码仍使用原接口,但必须提交本次定位,服务端按课程配置半径校验。 + this.submitLocatedSign(o, course.courseId) + }).catch((err) => { + vant.Dialog.alert({message: typeof err === 'string' ? err : '签到失败,请稍后重试'}) }) } if (o.signType === 2) { @@ -489,35 +515,86 @@ layout("/mobile/platform.html"){ vant.Toast('请在规定时间签到') return } - const that = this - geolocation.getCurrentPosition(async function (status, result) { - if (status === 'complete') { - locations = [result.position.getLng(), result.position.getLat()] - //用户当前位置 - const userPoint = new AMap.LngLat(result.position.getLng(), result.position.getLat()) - //课程位置 - const coursePoint = new AMap.LngLat(that.course.courseLocationCoordinates[0], that.course.courseLocationCoordinates[1]) - const distance = Math.round(userPoint.distance(coursePoint)) - if (distance > 250) { - vant.Toast('请在250米内签到') - return - } - const array = [result.position.getLng(), result.position.getLat()] - const resp = await $.post('/platform/mobile/brandTrainSignUpActivity/doQd', { - id: o.id, - courseId: that.course.id, - point: JSON.stringify(array), - }) - if (resp.code === 0) { - await that.pageData() - } - vant.Toast(resp.msg) - that.popupShow = false - } else { - } - }); + // 范围判断交给服务端,避免浏览器地图距离取整后误放行边界外的点。 + return this.submitLocatedSign(o) } }, + /** + * 无参数;返回jQuery Promise,成功值为本次GCJ-02定位的[经度,纬度]。 + * 每次签到重新定位,扫码入口无需先打开GPS地图;超时或定位失败不发送签到请求。 + */ + getSignPosition() { + const deferred = $.Deferred() + const timer = window.setTimeout(() => deferred.reject('定位超时,请重试'), 12000) + deferred.always(() => window.clearTimeout(timer)) + try { + if (!window.AMap || !AMap.plugin) { + deferred.reject('定位服务未加载,请刷新页面重试') + return deferred.promise() + } + AMap.plugin('AMap.Geolocation', () => { + if (deferred.state() !== 'pending') return + try { + const locator = new AMap.Geolocation({enableHighAccuracy: true, timeout: 10000, convert: true}) + locator.getCurrentPosition((status, result) => { + if (status !== 'complete' || !result || !result.position) { + deferred.reject('定位失败,请开启定位权限后重试') + return + } + const coordinates = [result.position.getLng(), result.position.getLat()] + if (!coordinates.every(value => Number.isFinite(value)) + || coordinates[0] < -180 || coordinates[0] > 180 + || coordinates[1] < -90 || coordinates[1] > 90) { + deferred.reject('定位坐标无效,请重新定位') + return + } + deferred.resolve(coordinates) + }) + } catch (err) { + deferred.reject('定位服务不可用,请刷新页面重试') + } + }) + } catch (err) { + deferred.reject('定位服务不可用,请刷新页面重试') + } + return deferred.promise() + }, + /** + * o为当前用户的课次签到记录;codeCourseId为旧二维码课程ID,缺省时走GPS入口。 + * 返回jQuery Promise;响应code=0成功,msg为提示,data为空,半径始终由服务端读取。 + */ + submitLocatedSign(o, codeCourseId) { + if (this.formLoading) return + this.$set(this, 'formLoading', true) + return this.getSignPosition().then(coordinates => { + if (codeCourseId) { + return $.post('/platform/mobile/brandTrainSignUpActivity/codeSign', { + id: o.id, codeCourseId: codeCourseId, clickCourseId: o.courseId, + longitude: coordinates[0], latitude: coordinates[1] + }) + } + return $.post('/platform/mobile/brandTrainSignUpActivity/doQd', { + id: o.id, courseId: o.courseId, point: JSON.stringify(coordinates) + }) + }).then(resp => { + if (resp.code === 0) { + this.$set(o, 'isAttend', true) + this.pageData() + } + vant.Toast(resp.msg || (resp.code === 0 ? '签到成功' : '签到失败')) + if (!codeCourseId) { + this.$set(this, 'popupShow', false) + } + }, err => { + vant.Toast(typeof err === 'string' ? err : '签到失败,请稍后重试') + }).always(() => { + this.$set(this, 'formLoading', false) + }) + }, + // course为课程对象;旧数据无半径时沿用250米,与服务端默认值保持一致。 + getSignRadius(course) { + return course.signRadius == null ? 250 : Number(course.signRadius) + }, //获取当前坐标点 getPoint() { /*return new Promise((resolve, reject) => { @@ -705,7 +782,7 @@ layout("/mobile/platform.html"){ var circle = new AMap.Circle({ center: new AMap.LngLat(o.courseLocationCoordinates[0], o.courseLocationCoordinates[1]), // 圆心位置 - radius: 250, // 圆半径 + radius: this.getSignRadius(o), // 当前课程配置的签到半径,单位为米 borderWeight: 1, strokeOpacity: 1, fillOpacity: 0.4, diff --git a/src/main/resources/views/mobile/scannerQrCode.html b/src/main/resources/views/mobile/scannerQrCode.html index 57f9e81..4e15265 100644 --- a/src/main/resources/views/mobile/scannerQrCode.html +++ b/src/main/resources/views/mobile/scannerQrCode.html @@ -30,6 +30,7 @@ layout("/mobile/platform.html"){ diff --git a/src/main/resources/views/platform/brandTrainSingUp/manage/activity.html b/src/main/resources/views/platform/brandTrainSingUp/manage/activity.html index 22febda..dabd17c 100644 --- a/src/main/resources/views/platform/brandTrainSingUp/manage/activity.html +++ b/src/main/resources/views/platform/brandTrainSingUp/manage/activity.html @@ -205,6 +205,8 @@ layout("/layouts/platform.html"){ courseDialog: false, trainType: '', trainTypeList: [], + welfareProjectList: [], + welfareProjectLoading: false, campusList: [], tableColumns: [ {label: '活动名称', prop: 'activityName'}, @@ -238,7 +240,8 @@ layout("/layouts/platform.html"){ this.$refs.openQRCode.openCode("/platform/mobile/brandTrainSignUpActivity/trainList") }, makeCode(row) { - this.clickRow = row + // 保留活动级配置,生成课程二维码时据此区分普通签到与签到扩展。 + this.$set(this, 'clickRow', row) const type = this.trainTypeList.find(o => o.code === row.trainType) this.trainType = type ? type.name : '培训班' this.courseDialog = true @@ -247,13 +250,43 @@ layout("/layouts/platform.html"){ const o = { courseId: row.id } - // 生成二维码的 base64 编码 - const content = jrQrcode.getQrBase64(JSON.stringify(o)) + // 未启用扩展的品牌活动保留原 JSON 二维码;扩展活动进入手机端达标处理页面。 + const qrText = this.isSignExtensionEnabled(this.clickRow) + ? window.location.origin + '/platform/mobile/brandTrain/scanCode?courseId=' + encodeURIComponent(row.id) + : JSON.stringify(o) + const content = jrQrcode.getQrBase64(qrText) let image = new Image() image.src = content let viewer = new Viewer(image) viewer.show() }, + // 只有全部课程签到且配置了正整数点位数的活动,才启用手机端扩展流程。 + isSignExtensionEnabled(activity) { + return activity && Number.isInteger(activity.signPointCount) && activity.signPointCount > 0 + && Array.isArray(activity.courseList) && activity.courseList.length > 0 + && activity.courseList.every(item => item.isMobileSign === true) + }, + // type 为活动签到处理方式编码,历史未配置时按无处理显示。 + getActivitySignHandleTypeName(type) { + const typeNames = {1: '系统自动弹出填写信息', 2: '系统自动选择礼物或福利', 3: '无'} + return typeNames[type] || '无' + }, + // welfareProjectId 为已关联项目 ID,项目失效不在候选列表时仍展示原 ID。 + getWelfareProjectName(welfareProjectId) { + const project = this.welfareProjectList.find(item => item.id === welfareProjectId) + return project ? project.name : (welfareProjectId || '-') + }, + // 无请求参数;响应 data 为含 id、name 的福利项目数组,供查看页解析项目名称。 + getAvailableWelfareProjectList() { + this.welfareProjectLoading = true + return $.post('/platform/brandTrainSingUp/manage/activity/getAvailableWelfareProjectList', {}) + .then(resp => { + this.$set(this, 'welfareProjectList', resp.code === 0 ? resp.data : []) + }) + .always(() => { + this.welfareProjectLoading = false + }) + }, openAdd() { sublime.jumpPagePjax('/platform/brandTrainSingUp/manage/activity/add') }, @@ -346,6 +379,7 @@ layout("/layouts/platform.html"){ }, }, async created() { + this.getAvailableWelfareProjectList() this.pageData() this.trainTypeList = await getDictOptions("trainSignUpType") this.campusList = await getCampus() diff --git a/src/main/resources/views/platform/brandTrainSingUp/manage/activity_add.html b/src/main/resources/views/platform/brandTrainSingUp/manage/activity_add.html index ac695c9..afd0db0 100644 --- a/src/main/resources/views/platform/brandTrainSingUp/manage/activity_add.html +++ b/src/main/resources/views/platform/brandTrainSingUp/manage/activity_add.html @@ -33,9 +33,9 @@ layout("/layouts/platform.html"){