浏览代码

新人问卷调查

xw 2 天之前
父节点
当前提交
ced6db1910

+ 86 - 0
fs-admin/src/main/java/com/fs/his/controller/FsNewcomerQuestionnaireController.java

@@ -0,0 +1,86 @@
+package com.fs.his.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.DateUtils;
+import com.fs.his.domain.FsNewcomerQuestionnaire;
+import com.fs.his.service.NewcomerQuestionnaireService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 新人问卷调查(动态表单 JSON:formSchema)
+ */
+@RestController
+@RequestMapping("/his/newcomerQuestionnaire")
+public class FsNewcomerQuestionnaireController extends BaseController {
+
+    @Autowired
+    private NewcomerQuestionnaireService newcomerQuestionnaireService;
+
+    @PreAuthorize("@ss.hasPermi('his:newcomerQuestionnaire:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsNewcomerQuestionnaire query) {
+        startPage();
+        List<FsNewcomerQuestionnaire> list = newcomerQuestionnaireService.selectList(query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('his:newcomerQuestionnaire:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable Long id) {
+        return AjaxResult.success(newcomerQuestionnaireService.selectById(id));
+    }
+
+    @PreAuthorize("@ss.hasPermi('his:newcomerQuestionnaire:add')")
+    @Log(title = "新人问卷调查", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@RequestBody FsNewcomerQuestionnaire row) {
+        row.setCreateTime(DateUtils.getNowDate());
+        row.setUpdateTime(DateUtils.getNowDate());
+        if (row.getSortOrder() == null) {
+            row.setSortOrder(0);
+        }
+        if (row.getStatus() == null) {
+            row.setStatus(1);
+        }
+        return toAjax(newcomerQuestionnaireService.insert(row));
+    }
+
+    @PreAuthorize("@ss.hasPermi('his:newcomerQuestionnaire:edit')")
+    @Log(title = "新人问卷调查", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@RequestBody FsNewcomerQuestionnaire row) {
+        row.setUpdateTime(DateUtils.getNowDate());
+        return toAjax(newcomerQuestionnaireService.update(row));
+    }
+
+    @PreAuthorize("@ss.hasPermi('his:newcomerQuestionnaire:remove')")
+    @Log(title = "新人问卷调查", businessType = BusinessType.DELETE)
+    @DeleteMapping("/{ids}")
+    public AjaxResult remove(@PathVariable Long[] ids) {
+        return toAjax(newcomerQuestionnaireService.deleteByIds(ids));
+    }
+
+    /**
+     * 当前 App 将使用的问卷(与系统参数 newcomer.welfare.questionnaire_id 及启用状态解析逻辑一致)
+     */
+    @PreAuthorize("@ss.hasPermi('his:newcomerQuestionnaire:query')")
+    @GetMapping("/appActive")
+    public AjaxResult appActive() {
+        return AjaxResult.success(newcomerQuestionnaireService.resolveForApp());
+    }
+}

+ 5 - 0
fs-service/src/main/java/com/fs/his/config/CouponConfig.java

@@ -8,4 +8,9 @@ import java.io.Serializable;
 public class CouponConfig implements Serializable {
     private Long[] registerCoupon;
     private Long userTaskCoupon;
+
+    /** 新手福利:手机号末位奇数路径(弹窗问卷后发放)对应的优惠券模板 ID */
+    private Long newcomerCouponA;
+    /** 新手福利:手机号末位偶数路径(直接发放)对应的优惠券模板 ID */
+    private Long newcomerCouponB;
 }

+ 24 - 0
fs-service/src/main/java/com/fs/his/domain/FsNewcomerQuestionnaire.java

@@ -0,0 +1,24 @@
+package com.fs.his.domain;
+
+import com.fs.common.annotation.Excel;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 新人问卷;form_schema 为 JSON:version + fields[{key,type,label 或 title,required,options}]
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FsNewcomerQuestionnaire extends BaseEntity {
+
+    private Long id;
+    @Excel(name = "名称")
+    private String name;
+    @Excel(name = "排序")
+    private Integer sortOrder;
+    @Excel(name = "状态")
+    private Integer status;
+    private String formSchema;
+    private String remark;
+}

+ 12 - 0
fs-service/src/main/java/com/fs/his/domain/FsUser.java

@@ -200,6 +200,18 @@ public class FsUser extends BaseEntity
 
     private String appleKey; // 苹果key登陆验证
 
+    /** 新手福利券类型:A=注册手机号末位奇数(问卷路径),B=末位偶数(直发路径) */
+    private String newcomerCouponType;
+    /** 新手问卷是否已完成:0否 1是 */
+    private Integer newcomerProfileDone;
+    /** 新手福利券是否已发放:0否 1是 */
+    private Integer newcomerWelfareGranted;
+
+    /** 已提交的新人问卷 id */
+    private Long newcomerQuestionnaireId;
+    /** 问卷答案 JSON(与 form_schema 字段 key 对应) */
+    private String newcomerAnswersJson;
+
     public void setNickName(String nickname)
     {
         if(StringUtils.isNotEmpty(nickname)){

+ 21 - 0
fs-service/src/main/java/com/fs/his/mapper/FsNewcomerQuestionnaireMapper.java

@@ -0,0 +1,21 @@
+package com.fs.his.mapper;
+
+import com.fs.his.domain.FsNewcomerQuestionnaire;
+import java.util.List;
+
+public interface FsNewcomerQuestionnaireMapper {
+
+    FsNewcomerQuestionnaire selectFsNewcomerQuestionnaireById(Long id);
+
+    List<FsNewcomerQuestionnaire> selectFsNewcomerQuestionnaireList(FsNewcomerQuestionnaire query);
+
+    FsNewcomerQuestionnaire selectFirstEnabledOrderBySort();
+
+    int insertFsNewcomerQuestionnaire(FsNewcomerQuestionnaire row);
+
+    int updateFsNewcomerQuestionnaire(FsNewcomerQuestionnaire row);
+
+    int deleteFsNewcomerQuestionnaireById(Long id);
+
+    int deleteFsNewcomerQuestionnaireByIds(Long[] ids);
+}

+ 3 - 0
fs-service/src/main/java/com/fs/his/param/FsUserCouponSendParam.java

@@ -21,4 +21,7 @@ public class FsUserCouponSendParam {
 
     //发送销售公司id
     private Long companyId;
+
+    /** 系统活动发券(如新手福利),为 true 时不写 sendUserId,避免依赖后台登录态 */
+    private Boolean systemSend;
 }

+ 61 - 0
fs-service/src/main/java/com/fs/his/service/NewcomerQuestionnaireService.java

@@ -0,0 +1,61 @@
+package com.fs.his.service;
+
+import com.fs.common.utils.StringUtils;
+import com.fs.his.domain.FsNewcomerQuestionnaire;
+import com.fs.his.mapper.FsNewcomerQuestionnaireMapper;
+import com.fs.system.service.ISysConfigService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 新人问卷:后台 CRUD + App 解析当前使用的问卷
+ */
+@Service
+public class NewcomerQuestionnaireService {
+
+    public static final String CONFIG_KEY_QUESTIONNAIRE_ID = "newcomer.welfare.questionnaire_id";
+
+    @Autowired
+    private FsNewcomerQuestionnaireMapper mapper;
+    @Autowired
+    private ISysConfigService configService;
+
+    public FsNewcomerQuestionnaire selectById(Long id) {
+        return mapper.selectFsNewcomerQuestionnaireById(id);
+    }
+
+    public List<FsNewcomerQuestionnaire> selectList(FsNewcomerQuestionnaire query) {
+        return mapper.selectFsNewcomerQuestionnaireList(query);
+    }
+
+    public int insert(FsNewcomerQuestionnaire row) {
+        return mapper.insertFsNewcomerQuestionnaire(row);
+    }
+
+    public int update(FsNewcomerQuestionnaire row) {
+        return mapper.updateFsNewcomerQuestionnaire(row);
+    }
+
+    public int deleteByIds(Long[] ids) {
+        return mapper.deleteFsNewcomerQuestionnaireByIds(ids);
+    }
+
+    /** 优先系统参数 newcomer.welfare.questionnaire_id,否则第一条 status=1 按排序 */
+    public FsNewcomerQuestionnaire resolveForApp() {
+        String idStr = configService.selectConfigByKey(CONFIG_KEY_QUESTIONNAIRE_ID);
+        if (StringUtils.isNotBlank(idStr)) {
+            try {
+                Long id = Long.parseLong(idStr.trim());
+                FsNewcomerQuestionnaire q = mapper.selectFsNewcomerQuestionnaireById(id);
+                if (q != null && q.getStatus() != null && q.getStatus() == 1) {
+                    return q;
+                }
+            } catch (NumberFormatException ignored) {
+                // ignore
+            }
+        }
+        return mapper.selectFirstEnabledOrderBySort();
+    }
+}

+ 414 - 0
fs-service/src/main/java/com/fs/his/service/NewcomerWelfareService.java

@@ -0,0 +1,414 @@
+package com.fs.his.service;
+
+import cn.hutool.json.JSONArray;
+import cn.hutool.json.JSONObject;
+import cn.hutool.json.JSONUtil;
+import com.fs.common.exception.CustomException;
+import com.fs.common.utils.StringUtils;
+import com.fs.his.config.CouponConfig;
+import com.fs.his.domain.FsNewcomerQuestionnaire;
+import com.fs.his.domain.FsUser;
+import com.fs.his.param.FsUserCouponSendParam;
+import com.fs.his.utils.PhoneUtil;
+import com.fs.his.vo.NewcomerWelfareStateVO;
+import com.fs.system.service.ISysConfigService;
+import lombok.Data;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.validation.constraints.NotNull;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 新手福利:手机号奇偶分流、动态问卷、发券
+ */
+@Service
+public class NewcomerWelfareService {
+
+    @Autowired
+    private IFsUserService fsUserService;
+    @Autowired
+    private IFsUserCouponService fsUserCouponService;
+    @Autowired
+    private NewcomerQuestionnaireService questionnaireService;
+    @Autowired
+    private ISysConfigService configService;
+
+    @Data
+    public static class SubmitBody implements Serializable {
+        @NotNull
+        private Long questionnaireId;
+        @NotNull
+        private Map<String, Object> answers;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public NewcomerWelfareStateVO getState(Long userId) {
+        FsUser user = fsUserService.selectFsUserByUserId(userId);
+        if (user == null) {
+            throw new CustomException("用户不存在");
+        }
+        assignCouponTypeIfNeeded(user);
+
+        NewcomerWelfareStateVO vo = new NewcomerWelfareStateVO();
+        String plain = resolvePlainPhone(user);
+        vo.setHasPhone(StringUtils.isNotBlank(plain));
+        vo.setNewcomerCouponType(user.getNewcomerCouponType());
+
+        boolean eligible = isEligibleByAccountAge(user);
+        boolean granted = isGranted(user);
+        vo.setWelfareGranted(granted);
+
+        FsNewcomerQuestionnaire q = questionnaireService.resolveForApp();
+        fillQuestionnaire(vo, q);
+
+        boolean pathA = "A".equals(user.getNewcomerCouponType());
+        boolean hasForm = q != null && StringUtils.isNotBlank(q.getFormSchema());
+        vo.setShowQuestionnaire(vo.isHasPhone() && eligible && pathA && !granted && !isProfileDone(user) && hasForm);
+
+        vo.setJustGranted(false);
+        if (vo.isHasPhone() && eligible && "B".equals(user.getNewcomerCouponType()) && !granted) {
+            tryGrantAndMark(user, "B");
+            vo.setJustGranted(true);
+            user = fsUserService.selectFsUserByUserId(userId);
+            vo.setWelfareGranted(isGranted(user));
+        }
+        return vo;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void submitQuestionnaire(Long userId, SubmitBody body) {
+        FsUser user = fsUserService.selectFsUserByUserId(userId);
+        if (user == null) {
+            throw new CustomException("用户不存在");
+        }
+        assignCouponTypeIfNeeded(user);
+        if (!"A".equals(user.getNewcomerCouponType())) {
+            throw new CustomException("当前账号无需提交问卷");
+        }
+        if (isGranted(user)) {
+            throw new CustomException("新手福利已领取");
+        }
+        if (isProfileDone(user)) {
+            throw new CustomException("问卷已完成");
+        }
+        if (!isEligibleByAccountAge(user)) {
+            throw new CustomException("当前账号不参与新手福利活动");
+        }
+
+        FsNewcomerQuestionnaire current = questionnaireService.resolveForApp();
+        if (current == null || current.getId() == null) {
+            throw new CustomException("问卷未配置");
+        }
+        if (!current.getId().equals(body.getQuestionnaireId())) {
+            throw new CustomException("问卷已更新,请返回刷新后重新填写");
+        }
+        if (StringUtils.isBlank(current.getFormSchema())) {
+            throw new CustomException("问卷表单为空");
+        }
+
+        validateAnswers(current.getFormSchema(), body.getAnswers());
+
+        FsUser patch = new FsUser();
+        patch.setUserId(userId);
+        patch.setNewcomerQuestionnaireId(current.getId());
+        patch.setNewcomerAnswersJson(JSONUtil.toJsonStr(body.getAnswers()));
+        patch.setNewcomerProfileDone(1);
+        syncSexFromAnswers(patch, body.getAnswers());
+        fsUserService.updateFsUser(patch);
+
+        user.setNewcomerProfileDone(1);
+        tryGrantAndMark(user, "A");
+    }
+
+    private void fillQuestionnaire(NewcomerWelfareStateVO vo, FsNewcomerQuestionnaire q) {
+        if (q == null) {
+            vo.setQuestionnaireId(null);
+            vo.setFormSchema(null);
+            vo.setSchemaVersion(1);
+            return;
+        }
+        vo.setQuestionnaireId(q.getId());
+        vo.setFormSchema(q.getFormSchema());
+        vo.setSchemaVersion(parseSchemaVersion(q.getFormSchema()));
+    }
+
+    private static int parseSchemaVersion(String formSchema) {
+        if (StringUtils.isBlank(formSchema)) {
+            return 1;
+        }
+        try {
+            Integer v = JSONUtil.parseObj(formSchema).getInt("version");
+            return v != null && v > 0 ? v : 1;
+        } catch (Exception e) {
+            return 1;
+        }
+    }
+
+    private void assignCouponTypeIfNeeded(FsUser user) {
+        if (StringUtils.isNotBlank(user.getNewcomerCouponType())) {
+            return;
+        }
+        String plain = resolvePlainPhone(user);
+        if (StringUtils.isBlank(plain)) {
+            return;
+        }
+        char last = plain.charAt(plain.length() - 1);
+        int d = (last >= '0' && last <= '9') ? (last - '0') : 0;
+        String type = (last >= '0' && last <= '9' && d % 2 == 1) ? "A" : "B";
+
+        FsUser patch = new FsUser();
+        patch.setUserId(user.getUserId());
+        patch.setNewcomerCouponType(type);
+        fsUserService.updateFsUser(patch);
+        user.setNewcomerCouponType(type);
+    }
+
+    private String resolvePlainPhone(FsUser user) {
+        if (user == null || StringUtils.isBlank(user.getPhone())) {
+            return null;
+        }
+        String p = user.getPhone().trim();
+        if (p.matches("^1\\d{10}$")) {
+            return p;
+        }
+        String d = PhoneUtil.decryptPhone(p);
+        if (StringUtils.isNotBlank(d) && d.matches("^1\\d{10}$")) {
+            return d;
+        }
+        d = PhoneUtil.decryptPhoneOldKey(p);
+        if (StringUtils.isNotBlank(d) && d.matches("^1\\d{10}$")) {
+            return d;
+        }
+        return null;
+    }
+
+    private boolean isEligibleByAccountAge(FsUser user) {
+        String daysStr = configService.selectConfigByKey("newcomer.welfare.max_account_age_days");
+        if (StringUtils.isBlank(daysStr)) {
+            return true;
+        }
+        int days;
+        try {
+            days = Integer.parseInt(daysStr.trim());
+        } catch (NumberFormatException e) {
+            return true;
+        }
+        if (days <= 0 || user.getCreateTime() == null) {
+            return true;
+        }
+        long cutoff = System.currentTimeMillis() - (long) days * 86_400_000L;
+        return user.getCreateTime().getTime() >= cutoff;
+    }
+
+    private static boolean isGranted(FsUser user) {
+        return user.getNewcomerWelfareGranted() != null && user.getNewcomerWelfareGranted() == 1;
+    }
+
+    private static boolean isProfileDone(FsUser user) {
+        return user.getNewcomerProfileDone() != null && user.getNewcomerProfileDone() == 1;
+    }
+
+    private void tryGrantAndMark(FsUser user, String path) {
+        Long couponId = resolveCouponId(path);
+        if (couponId == null) {
+            throw new CustomException("未配置新手福利券,请在参数 his.coupon 中配置 newcomerCoupon" + path);
+        }
+        FsUserCouponSendParam sendParam = new FsUserCouponSendParam();
+        sendParam.setUserId(user.getUserId());
+        sendParam.setCouponId(couponId);
+        sendParam.setSystemSend(true);
+        fsUserCouponService.sendFsUserCoupon(sendParam);
+
+        FsUser patch = new FsUser();
+        patch.setUserId(user.getUserId());
+        patch.setNewcomerWelfareGranted(1);
+        fsUserService.updateFsUser(patch);
+        user.setNewcomerWelfareGranted(1);
+    }
+
+    private Long resolveCouponId(String path) {
+        String json = configService.selectConfigByKey("his.coupon");
+        if (StringUtils.isBlank(json)) {
+            return null;
+        }
+        CouponConfig cfg = JSONUtil.toBean(json, CouponConfig.class);
+        if (cfg == null) {
+            return null;
+        }
+        return "A".equals(path) ? cfg.getNewcomerCouponA() : cfg.getNewcomerCouponB();
+    }
+
+    private void syncSexFromAnswers(FsUser patch, Map<String, Object> answers) {
+        if (answers == null) {
+            return;
+        }
+        Object s = answers.get("sex");
+        if (s == null) {
+            return;
+        }
+        try {
+            int v = Integer.parseInt(String.valueOf(s).trim());
+            if (v == 1 || v == 2) {
+                patch.setSex(v);
+            }
+        } catch (NumberFormatException ignored) {
+            // ignore
+        }
+    }
+
+    // ---------- 答案与 form_schema 校验(原独立类内聚至此) ----------
+    /** 展示名:优先 label,兼容仅设计器使用的 title */
+    private static String fieldDisplay(JSONObject f, String keyFallback) {
+        if (f == null) {
+            return keyFallback;
+        }
+        if (StringUtils.isNotBlank(f.getStr("label"))) {
+            return f.getStr("label");
+        }
+        if (StringUtils.isNotBlank(f.getStr("title"))) {
+            return f.getStr("title");
+        }
+        return keyFallback;
+    }
+
+    private void validateAnswers(String formSchemaJson, Map<String, Object> answers) {
+        if (StringUtils.isBlank(formSchemaJson)) {
+            throw new CustomException("问卷未配置表单");
+        }
+        JSONArray fields = JSONUtil.parseObj(formSchemaJson).getJSONArray("fields");
+        if (fields == null || fields.isEmpty()) {
+            return;
+        }
+        for (int i = 0; i < fields.size(); i++) {
+            JSONObject f = fields.getJSONObject(i);
+            if (f == null) {
+                continue;
+            }
+            String key = f.getStr("key");
+            if (StringUtils.isBlank(key)) {
+                throw new CustomException("问卷配置异常:题目缺少 key");
+            }
+            String type = StringUtils.defaultString(f.getStr("type"), "text").toLowerCase();
+            boolean required = f.getBool("required", false);
+            Object raw = answers == null ? null : answers.get(key);
+
+            if (required && isAnswerEmpty(raw, type)) {
+                throw new CustomException("请填写:" + fieldDisplay(f, key));
+            }
+            if (isAnswerEmpty(raw, type)) {
+                continue;
+            }
+            switch (type) {
+                case "radio":
+                    assertRadio(f, raw);
+                    break;
+                case "checkbox":
+                    assertCheckbox(f, raw);
+                    break;
+                case "number":
+                    assertNumber(raw, fieldDisplay(f, key));
+                    break;
+                case "text":
+                default:
+                    if (!(raw instanceof String) && raw != null) {
+                        throw new CustomException("题目格式不正确:" + fieldDisplay(f, key));
+                    }
+                    break;
+            }
+        }
+    }
+
+    private static boolean isAnswerEmpty(Object raw, String type) {
+        if (raw == null) {
+            return true;
+        }
+        if ("checkbox".equals(type)) {
+            List<?> list = normalizeList(raw);
+            return list == null || list.isEmpty();
+        }
+        if (raw instanceof String) {
+            return StringUtils.isBlank((String) raw);
+        }
+        return false;
+    }
+
+    private static void assertRadio(JSONObject field, Object raw) {
+        String v = raw == null ? null : String.valueOf(raw).trim();
+        if (!optionValues(field).contains(v)) {
+            throw new CustomException("选项不合法:" + fieldDisplay(field, field.getStr("key")));
+        }
+    }
+
+    private static void assertCheckbox(JSONObject field, Object raw) {
+        List<?> list = normalizeList(raw);
+        if (list == null || list.isEmpty()) {
+            return;
+        }
+        Set<String> allowed = optionValues(field);
+        for (Object o : list) {
+            String v = o == null ? "" : String.valueOf(o).trim();
+            if (!allowed.contains(v)) {
+                throw new CustomException("多选项不合法:" + fieldDisplay(field, field.getStr("key")));
+            }
+        }
+    }
+
+    private static void assertNumber(Object raw, String label) {
+        if (raw instanceof Number) {
+            return;
+        }
+        if (raw instanceof String) {
+            try {
+                Double.parseDouble(((String) raw).trim());
+                return;
+            } catch (NumberFormatException ignored) {
+                // fall through
+            }
+        }
+        throw new CustomException("请输入有效数字:" + label);
+    }
+
+    private static Set<String> optionValues(JSONObject field) {
+        Set<String> set = new HashSet<>();
+        JSONArray opts = field.getJSONArray("options");
+        if (opts == null) {
+            return set;
+        }
+        for (int j = 0; j < opts.size(); j++) {
+            JSONObject o = opts.getJSONObject(j);
+            if (o != null && o.get("value") != null) {
+                set.add(String.valueOf(o.get("value")).trim());
+            }
+        }
+        return set;
+    }
+
+    private static List<?> normalizeList(Object raw) {
+        if (raw == null) {
+            return null;
+        }
+        if (raw instanceof List) {
+            return (List<?>) raw;
+        }
+        if (raw instanceof Collection) {
+            return new ArrayList<>((Collection<?>) raw);
+        }
+        if (raw instanceof JSONArray) {
+            JSONArray ja = (JSONArray) raw;
+            List<Object> list = new ArrayList<>(ja.size());
+            for (int i = 0; i < ja.size(); i++) {
+                list.add(ja.get(i));
+            }
+            return list;
+        }
+        return null;
+    }
+}

+ 5 - 1
fs-service/src/main/java/com/fs/his/service/impl/FsUserCouponServiceImpl.java

@@ -163,7 +163,11 @@ public class FsUserCouponServiceImpl implements IFsUserCouponService
         fsUserCoupon.setCouponCode("C"+System.currentTimeMillis());
         fsUserCoupon.setUserId(param.getUserId());
         fsUserCoupon.setCreateTime(DateUtils.getNowDate());
-        if (param.getCompanyUserId() == null && param.getCompanyId() == null){
+        if (Boolean.TRUE.equals(param.getSystemSend())) {
+            fsUserCoupon.setSendUserId(null);
+            fsUserCoupon.setCompanyId(null);
+            fsUserCoupon.setCompanyUserId(null);
+        } else if (param.getCompanyUserId() == null && param.getCompanyId() == null){
             fsUserCoupon.setSendUserId(SecurityUtils.getUserId());
             fsUserCoupon.setCompanyId(null);
             fsUserCoupon.setCompanyUserId(null);

+ 20 - 0
fs-service/src/main/java/com/fs/his/utils/PhoneUtil.java

@@ -97,4 +97,24 @@ public class PhoneUtil {
         }
         return encryptedText;
     }
+
+    /**
+     * 与 {@link #encryptPhoneOldKey(String)} 对应的解密
+     */
+    public static String decryptPhoneOldKey(String encryptedText) {
+        String text = null;
+        if (encryptedText == null || encryptedText.isEmpty()) {
+            return null;
+        }
+        try {
+            SecretKeySpec secretKey = new SecretKeySpec(OLD_KEY.getBytes(), "AES");
+            Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
+            cipher.init(Cipher.DECRYPT_MODE, secretKey);
+            byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedText));
+            text = new String(decryptedBytes);
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return text;
+    }
 }

+ 37 - 0
fs-service/src/main/java/com/fs/his/vo/NewcomerWelfareStateVO.java

@@ -0,0 +1,37 @@
+package com.fs.his.vo;
+
+import lombok.Data;
+
+/**
+ * App 新手福利弹窗状态
+ */
+@Data
+public class NewcomerWelfareStateVO {
+
+    /** 是否已绑定可解析的 11 位手机号 */
+    private boolean hasPhone;
+
+    /** A / B,无手机号时可能为 null */
+    private String newcomerCouponType;
+
+    /** 是否展示问卷(路径 A 且未完成、未发券,且已配置有效问卷) */
+    private boolean showQuestionnaire;
+
+    /** 是否已发放新手福利券 */
+    private boolean welfareGranted;
+
+    /** 本次请求是否为路径 B 且刚完成自动发券 */
+    private boolean justGranted;
+
+    /** 当前 App 使用的问卷 id(无则 null) */
+    private Long questionnaireId;
+
+    /** schema 版本号,来自 form_schema JSON 根节点 version,默认 1 */
+    private Integer schemaVersion;
+
+    /**
+     * 动态表单 JSON 字符串,App 解析后渲染;
+     * 与总后台「新人问卷调查」中保存的 form_schema 一致
+     */
+    private String formSchema;
+}

+ 89 - 0
fs-service/src/main/resources/mapper/his/FsNewcomerQuestionnaireMapper.xml

@@ -0,0 +1,89 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.his.mapper.FsNewcomerQuestionnaireMapper">
+
+    <resultMap type="FsNewcomerQuestionnaire" id="FsNewcomerQuestionnaireResult">
+        <result property="id" column="id"/>
+        <result property="name" column="name"/>
+        <result property="sortOrder" column="sort_order"/>
+        <result property="status" column="status"/>
+        <result property="formSchema" column="form_schema"/>
+        <result property="remark" column="remark"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateTime" column="update_time"/>
+    </resultMap>
+
+    <sql id="selectVo">
+        select id, name, sort_order, status, form_schema, remark, create_time, update_time
+        from fs_newcomer_questionnaire
+    </sql>
+
+    <select id="selectFsNewcomerQuestionnaireById" resultMap="FsNewcomerQuestionnaireResult">
+        <include refid="selectVo"/>
+        where id = #{id}
+    </select>
+
+    <select id="selectFsNewcomerQuestionnaireList" resultMap="FsNewcomerQuestionnaireResult">
+        <include refid="selectVo"/>
+        <where>
+            <if test="name != null and name != ''">and name like concat('%', #{name}, '%')</if>
+            <if test="status != null">and status = #{status}</if>
+        </where>
+        order by sort_order asc, id asc
+    </select>
+
+    <select id="selectFirstEnabledOrderBySort" resultMap="FsNewcomerQuestionnaireResult">
+        <include refid="selectVo"/>
+        where status = 1
+        order by sort_order asc, id asc
+        limit 1
+    </select>
+
+    <insert id="insertFsNewcomerQuestionnaire" parameterType="FsNewcomerQuestionnaire" useGeneratedKeys="true" keyProperty="id">
+        insert into fs_newcomer_questionnaire
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="name != null">name,</if>
+            <if test="sortOrder != null">sort_order,</if>
+            <if test="status != null">status,</if>
+            <if test="formSchema != null">form_schema,</if>
+            <if test="remark != null">remark,</if>
+            <if test="createTime != null">create_time,</if>
+            <if test="updateTime != null">update_time,</if>
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="name != null">#{name},</if>
+            <if test="sortOrder != null">#{sortOrder},</if>
+            <if test="status != null">#{status},</if>
+            <if test="formSchema != null">#{formSchema},</if>
+            <if test="remark != null">#{remark},</if>
+            <if test="createTime != null">#{createTime},</if>
+            <if test="updateTime != null">#{updateTime},</if>
+        </trim>
+    </insert>
+
+    <update id="updateFsNewcomerQuestionnaire" parameterType="FsNewcomerQuestionnaire">
+        update fs_newcomer_questionnaire
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="name != null">name = #{name},</if>
+            <if test="sortOrder != null">sort_order = #{sortOrder},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="formSchema != null">form_schema = #{formSchema},</if>
+            <if test="remark != null">remark = #{remark},</if>
+            <if test="updateTime != null">update_time = #{updateTime},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteFsNewcomerQuestionnaireById">
+        delete from fs_newcomer_questionnaire where id = #{id}
+    </delete>
+
+    <delete id="deleteFsNewcomerQuestionnaireByIds">
+        delete from fs_newcomer_questionnaire where id in
+        <foreach collection="array" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+</mapper>

+ 5 - 0
fs-service/src/main/resources/mapper/his/FsUserMapper.xml

@@ -723,6 +723,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="appId != null">app_id = #{appId},</if>
             <if test="appOpenId != null">app_open_id = #{appOpenId},</if>
             <if test="appleKey != null">apple_key = #{appleKey},</if>
+            <if test="newcomerCouponType != null">newcomer_coupon_type = #{newcomerCouponType},</if>
+            <if test="newcomerProfileDone != null">newcomer_profile_done = #{newcomerProfileDone},</if>
+            <if test="newcomerWelfareGranted != null">newcomer_welfare_granted = #{newcomerWelfareGranted},</if>
+            <if test="newcomerQuestionnaireId != null">newcomer_questionnaire_id = #{newcomerQuestionnaireId},</if>
+            <if test="newcomerAnswersJson != null">newcomer_answers_json = #{newcomerAnswersJson},</if>
         </trim>
         where user_id = #{userId}
     </update>

+ 41 - 0
fs-user-app/src/main/java/com/fs/app/controller/NewcomerWelfareController.java

@@ -0,0 +1,41 @@
+package com.fs.app.controller;
+
+import com.fs.app.annotation.Login;
+import com.fs.common.core.domain.R;
+import com.fs.his.service.NewcomerWelfareService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+
+@Api("新手福利弹窗")
+@RestController
+@RequestMapping("/app/newcomerWelfare")
+public class NewcomerWelfareController extends AppBaseController {
+
+    @Autowired
+    private NewcomerWelfareService newcomerWelfareService;
+
+    @Login
+    @ApiOperation("新手福利状态(含 formSchema;路径B会尝试自动发券)")
+    @GetMapping("/state")
+    public R state() {
+        Long userId = Long.parseLong(getUserId());
+        return R.ok().put("data", newcomerWelfareService.getState(userId));
+    }
+
+    @Login
+    @ApiOperation("提交动态问卷并领取福利(仅路径A,answers 与 form_schema 字段 key 对应)")
+    @PostMapping("/submitQuestionnaire")
+    public R submitQuestionnaire(@Valid @RequestBody NewcomerWelfareService.SubmitBody body) {
+        Long userId = Long.parseLong(getUserId());
+        newcomerWelfareService.submitQuestionnaire(userId, body);
+        return R.ok("提交成功");
+    }
+}