| | |
| | | (1327, 1324, '编辑平台资质', 'system_platform_qualification_edit', 'B', NULL, NULL, NULL, 3, 1, 1, 'system:platform-qualification:edit', '平台资质编辑按钮', 0), |
| | | (1328, 1324, '删除平台资质', 'system_platform_qualification_delete', 'B', NULL, NULL, NULL, 4, 1, 1, 'system:platform-qualification:delete', '平台资质删除按钮', 0), |
| | | (1329, 1324, '切换平台资质上架状态', 'system_platform_qualification_publish_status', 'B', NULL, NULL, NULL, 5, 1, 1, 'system:platform-qualification:publish-status', '平台资质上架状态按钮', 0), |
| | | (1330, 1300, '协议内容', 'system_agreement_content', 'C', '/system/settings/agreement-content', 'System/AgreementContent', NULL, 16, 1, 1, 'system:agreement-content:view', '协议内容菜单', 0), |
| | | (1331, 1330, '编辑协议内容', 'system_agreement_content_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:agreement-content:edit', '协议内容编辑按钮', 0), |
| | | (1310, 1300, '积分设置', 'system_points_settings', 'C', '/system/settings/points', 'System/PointsSettings', NULL, 10, 1, 1, 'system:points-settings:view', '积分设置菜单', 0), |
| | | (1311, 1310, '编辑积分设置', 'system_points_settings_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:points-settings:edit', '积分设置编辑按钮', 0), |
| | | (2000, 0, '用户与会员', 'member_root', 'M', '/member', NULL, 'team', 200, 1, 1, NULL, '用户与会员模块', 0), |
| | |
| | | INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | VALUES |
| | | (1, 1000), (1, 1100), (1, 1101), (1, 1102), (1, 1103), (1, 1104), (1, 1105), |
| | | (1, 1200), (1, 1201), (1, 1202), (1, 1203), (1, 1204), (1, 1300), (1, 1305), (1, 1306), (1, 1307), (1, 1308), (1, 1309), (1, 1310), (1, 1311), (1, 1312), (1, 1313), (1, 1314), (1, 1315), (1, 1316), (1, 1317), (1, 1318), (1, 1319), (1, 1320), (1, 1321), (1, 1322), (1, 1323), (1, 1324), (1, 1325), (1, 1326), (1, 1327), (1, 1328), (1, 1329), |
| | | (1, 1200), (1, 1201), (1, 1202), (1, 1203), (1, 1204), (1, 1300), (1, 1305), (1, 1306), (1, 1307), (1, 1308), (1, 1309), (1, 1310), (1, 1311), (1, 1312), (1, 1313), (1, 1314), (1, 1315), (1, 1316), (1, 1317), (1, 1318), (1, 1319), (1, 1320), (1, 1321), (1, 1322), (1, 1323), (1, 1324), (1, 1325), (1, 1326), (1, 1327), (1, 1328), (1, 1329), (1, 1330), (1, 1331), |
| | | (1, 2000), (1, 2100), (1, 2110), (1, 2111), (1, 2112), (1, 2113), (1, 2114), (1, 2115), (1, 2116), (1, 2117), (1, 2118), (1, 2119), |
| | | (1, 2200), (1, 2210), (1, 2211), (1, 2220), (1, 2221), |
| | | (1, 3000), (1, 3050), (1, 3060), (1, 3061), (1, 3062), (1, 3063), |
| | |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台资质表'; |
| | | |
| | | -- ---------------------------- |
| | | -- 协议内容 |
| | | -- ---------------------------- |
| | | CREATE TABLE IF NOT EXISTS `tcm_agreement_content` ( |
| | | `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, |
| | | `agreement_type` VARCHAR(64) NOT NULL COMMENT '协议类型', |
| | | `content_html` LONGTEXT DEFAULT NULL COMMENT '协议内容富文本', |
| | | `deleted` TINYINT(1) NOT NULL DEFAULT 0, |
| | | `created_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '创建人', |
| | | `updated_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '更新人', |
| | | `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| | | `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
| | | PRIMARY KEY (`id`), |
| | | UNIQUE KEY `uk_agreement_content_type_deleted` (`agreement_type`, `deleted`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='协议内容表'; |
| | | |
| | | -- ---------------------------- |
| | | -- 积分设置 |
| | | -- ---------------------------- |
| | | CREATE TABLE IF NOT EXISTS `tcm_points_config` ( |
| New file |
| | |
| | | CREATE TABLE IF NOT EXISTS `tcm_agreement_content` ( |
| | | `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, |
| | | `agreement_type` VARCHAR(64) NOT NULL COMMENT '协议类型', |
| | | `content_html` LONGTEXT DEFAULT NULL COMMENT '协议内容富文本', |
| | | `deleted` TINYINT(1) NOT NULL DEFAULT 0, |
| | | `created_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '创建人', |
| | | `updated_by` VARCHAR(64) NOT NULL DEFAULT '系统初始化' COMMENT '更新人', |
| | | `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| | | `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
| | | PRIMARY KEY (`id`), |
| | | UNIQUE KEY `uk_agreement_content_type_deleted` (`agreement_type`, `deleted`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='协议内容表'; |
| | | |
| | | INSERT IGNORE INTO `sys_menu` |
| | | (`id`, `parent_id`, `name`, `menu_code`, `type`, `path`, `component`, `icon`, `sort`, `status`, `visible`, `permission_key`, `remark`, `deleted`) |
| | | VALUES |
| | | (1300, 1000, '系统设置', 'system_setting_group', 'M', '/system/settings', NULL, NULL, 30, 1, 1, NULL, '系统设置目录', 0), |
| | | (1330, 1300, '协议内容', 'system_agreement_content', 'C', '/system/settings/agreement-content', 'System/AgreementContent', NULL, 16, 1, 1, 'system:agreement-content:view', '协议内容菜单', 0), |
| | | (1331, 1330, '编辑协议内容', 'system_agreement_content_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'system:agreement-content:edit', '协议内容编辑按钮', 0); |
| | | |
| | | INSERT IGNORE INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | VALUES |
| | | (1, 1300), (1, 1330), (1, 1331); |
| | |
| | | null, null, null, 4, "system:platform-qualification:delete", "平台资质删除按钮")); |
| | | menuIds.add(saveMenu(1329L, 1324L, "切换平台资质上架状态", "system_platform_qualification_publish_status", "B", |
| | | null, null, null, 5, "system:platform-qualification:publish-status", "平台资质上架状态按钮")); |
| | | menuIds.add(saveMenu(1330L, 1300L, "协议内容", "system_agreement_content", "C", |
| | | "/system/settings/agreement-content", "System/AgreementContent", null, 16, "system:agreement-content:view", "协议内容菜单")); |
| | | menuIds.add(saveMenu(1331L, 1330L, "编辑协议内容", "system_agreement_content_edit", "B", |
| | | null, null, null, 1, "system:agreement-content:edit", "协议内容编辑按钮")); |
| | | menuIds.add(saveMenu(1310L, 1300L, "积分设置", "system_points_settings", "C", |
| | | "/system/settings/points", "System/PointsSettings", null, 10, "system:points-settings:view", "积分设置菜单")); |
| | | menuIds.add(saveMenu(1311L, 1310L, "编辑积分设置", "system_points_settings_edit", "B", |
| New file |
| | |
| | | package com.tcm.training.admin.system.controller; |
| | | |
| | | import com.tcm.training.admin.common.ApiResponse; |
| | | import com.tcm.training.admin.system.dto.AgreementContentSaveRequest; |
| | | import com.tcm.training.admin.system.service.AgreementContentAppService; |
| | | import com.tcm.training.admin.system.vo.AgreementContentItemVO; |
| | | import org.springframework.validation.annotation.Validated; |
| | | import org.springframework.web.bind.annotation.GetMapping; |
| | | import org.springframework.web.bind.annotation.PathVariable; |
| | | 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 javax.validation.Valid; |
| | | import java.util.List; |
| | | |
| | | @Validated |
| | | @RestController |
| | | @RequestMapping({"/api/v1", "/api"}) |
| | | public class AgreementContentController { |
| | | |
| | | private final AgreementContentAppService agreementContentAppService; |
| | | |
| | | public AgreementContentController(AgreementContentAppService agreementContentAppService) { |
| | | this.agreementContentAppService = agreementContentAppService; |
| | | } |
| | | |
| | | @GetMapping("/agreement-contents") |
| | | public ApiResponse<List<AgreementContentItemVO>> list() { |
| | | return ApiResponse.ok(agreementContentAppService.list()); |
| | | } |
| | | |
| | | @PutMapping("/agreement-contents/{agreementType}") |
| | | public ApiResponse<AgreementContentItemVO> save(@PathVariable("agreementType") String agreementType, |
| | | @Valid @RequestBody AgreementContentSaveRequest request) { |
| | | return ApiResponse.ok(agreementContentAppService.save(agreementType, request)); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.system.dto; |
| | | |
| | | import lombok.Getter; |
| | | import lombok.NoArgsConstructor; |
| | | import lombok.Setter; |
| | | |
| | | @Getter |
| | | @Setter |
| | | @NoArgsConstructor |
| | | public class AgreementContentSaveRequest { |
| | | |
| | | private String contentHtml; |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.system.entity; |
| | | |
| | | import com.baomidou.mybatisplus.annotation.FieldFill; |
| | | import com.baomidou.mybatisplus.annotation.IdType; |
| | | import com.baomidou.mybatisplus.annotation.TableField; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableLogic; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import lombok.Getter; |
| | | import lombok.NoArgsConstructor; |
| | | import lombok.Setter; |
| | | |
| | | import java.time.LocalDateTime; |
| | | |
| | | @TableName("tcm_agreement_content") |
| | | @Getter |
| | | @Setter |
| | | @NoArgsConstructor |
| | | public class AgreementContent { |
| | | |
| | | public static final String TYPE_USER_AGREEMENT = "USER_AGREEMENT"; |
| | | public static final String TYPE_PRIVACY_POLICY = "PRIVACY_POLICY"; |
| | | public static final String TYPE_INVITE_CODE_INSTRUCTIONS = "INVITE_CODE_INSTRUCTIONS"; |
| | | |
| | | @TableId(type = IdType.AUTO) |
| | | private Long id; |
| | | |
| | | @TableField("agreement_type") |
| | | private String agreementType; |
| | | |
| | | @TableField("content_html") |
| | | private String contentHtml; |
| | | |
| | | @TableLogic |
| | | private Integer deleted; |
| | | |
| | | @TableField(value = "created_by", fill = FieldFill.INSERT) |
| | | private String createdBy; |
| | | |
| | | @TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE) |
| | | private String updatedBy; |
| | | |
| | | @TableField("created_at") |
| | | private LocalDateTime createdAt; |
| | | |
| | | @TableField("updated_at") |
| | | private LocalDateTime updatedAt; |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.system.mapper; |
| | | |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import com.tcm.training.admin.system.entity.AgreementContent; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | |
| | | @Mapper |
| | | public interface AgreementContentMapper extends BaseMapper<AgreementContent> { |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.system.service; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.tcm.training.admin.system.dto.AgreementContentSaveRequest; |
| | | import com.tcm.training.admin.system.entity.AgreementContent; |
| | | import com.tcm.training.admin.system.mapper.AgreementContentMapper; |
| | | import com.tcm.training.admin.system.vo.AgreementContentItemVO; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | | |
| | | @Service |
| | | public class AgreementContentAppService { |
| | | |
| | | private static final String[] AGREEMENT_TYPES = new String[]{ |
| | | AgreementContent.TYPE_USER_AGREEMENT, |
| | | AgreementContent.TYPE_PRIVACY_POLICY, |
| | | AgreementContent.TYPE_INVITE_CODE_INSTRUCTIONS |
| | | }; |
| | | |
| | | private final AgreementContentMapper agreementContentMapper; |
| | | |
| | | public AgreementContentAppService(AgreementContentMapper agreementContentMapper) { |
| | | this.agreementContentMapper = agreementContentMapper; |
| | | } |
| | | |
| | | public List<AgreementContentItemVO> list() { |
| | | List<AgreementContent> records = agreementContentMapper.selectList(new LambdaQueryWrapper<AgreementContent>() |
| | | .eq(AgreementContent::getDeleted, 0)); |
| | | List<AgreementContentItemVO> result = new ArrayList<AgreementContentItemVO>(); |
| | | for (String agreementType : AGREEMENT_TYPES) { |
| | | result.add(toItemVO(agreementType, findByType(records, agreementType))); |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | @Transactional |
| | | public AgreementContentItemVO save(String agreementType, AgreementContentSaveRequest request) { |
| | | String normalizedType = normalizeAgreementType(agreementType); |
| | | String contentHtml = normalizeContent(request == null ? null : request.getContentHtml()); |
| | | AgreementContent record = agreementContentMapper.selectOne(new LambdaQueryWrapper<AgreementContent>() |
| | | .eq(AgreementContent::getAgreementType, normalizedType) |
| | | .eq(AgreementContent::getDeleted, 0) |
| | | .last("limit 1")); |
| | | if (record == null) { |
| | | record = new AgreementContent(); |
| | | record.setAgreementType(normalizedType); |
| | | record.setContentHtml(contentHtml); |
| | | record.setDeleted(0); |
| | | agreementContentMapper.insert(record); |
| | | } else { |
| | | record.setContentHtml(contentHtml); |
| | | agreementContentMapper.updateById(record); |
| | | } |
| | | return toItemVO(normalizedType, record); |
| | | } |
| | | |
| | | private AgreementContent findByType(List<AgreementContent> records, String agreementType) { |
| | | for (AgreementContent record : records) { |
| | | if (agreementType.equals(record.getAgreementType())) { |
| | | return record; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | private AgreementContentItemVO toItemVO(String agreementType, AgreementContent record) { |
| | | AgreementContentItemVO vo = new AgreementContentItemVO(); |
| | | vo.setAgreementType(agreementType); |
| | | vo.setAgreementName(resolveAgreementName(agreementType)); |
| | | vo.setContentHtml(record == null || record.getContentHtml() == null ? "" : record.getContentHtml()); |
| | | return vo; |
| | | } |
| | | |
| | | private String normalizeAgreementType(String agreementType) { |
| | | String normalized = trimToNull(agreementType); |
| | | if (normalized == null) { |
| | | throw new IllegalArgumentException("协议类型不合法"); |
| | | } |
| | | normalized = normalized.toUpperCase(); |
| | | for (String item : AGREEMENT_TYPES) { |
| | | if (item.equals(normalized)) { |
| | | return normalized; |
| | | } |
| | | } |
| | | throw new IllegalArgumentException("协议类型不合法"); |
| | | } |
| | | |
| | | private String resolveAgreementName(String agreementType) { |
| | | if (AgreementContent.TYPE_USER_AGREEMENT.equals(agreementType)) { |
| | | return "用户协议"; |
| | | } |
| | | if (AgreementContent.TYPE_PRIVACY_POLICY.equals(agreementType)) { |
| | | return "隐私协议"; |
| | | } |
| | | if (AgreementContent.TYPE_INVITE_CODE_INSTRUCTIONS.equals(agreementType)) { |
| | | return "获取邀请码说明"; |
| | | } |
| | | throw new IllegalArgumentException("协议类型不合法"); |
| | | } |
| | | |
| | | private String normalizeContent(String contentHtml) { |
| | | String trimmed = trimToNull(contentHtml); |
| | | if (trimmed == null || isBlankRichText(trimmed)) { |
| | | throw new IllegalArgumentException("请输入协议内容"); |
| | | } |
| | | return trimmed; |
| | | } |
| | | |
| | | private boolean isBlankRichText(String html) { |
| | | String normalized = html |
| | | .replaceAll("(?i)<br\\s*/?>", "") |
| | | .replaceAll("(?i)</p>", "") |
| | | .replace(" ", "") |
| | | .replace(" ", "") |
| | | .replaceAll("<[^>]+>", "") |
| | | .trim(); |
| | | if (!normalized.isEmpty()) { |
| | | return false; |
| | | } |
| | | String lowerCaseHtml = html.toLowerCase(); |
| | | return !lowerCaseHtml.contains("<img") |
| | | && !lowerCaseHtml.contains("<video") |
| | | && !lowerCaseHtml.contains("<audio") |
| | | && !lowerCaseHtml.contains("<iframe"); |
| | | } |
| | | |
| | | private String trimToNull(String value) { |
| | | if (value == null) { |
| | | return null; |
| | | } |
| | | String trimmed = value.trim(); |
| | | return trimmed.isEmpty() ? null : trimmed; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.system.vo; |
| | | |
| | | import lombok.Getter; |
| | | import lombok.NoArgsConstructor; |
| | | import lombok.Setter; |
| | | |
| | | @Getter |
| | | @Setter |
| | | @NoArgsConstructor |
| | | public class AgreementContentItemVO { |
| | | |
| | | private String agreementType; |
| | | private String agreementName; |
| | | private String contentHtml; |
| | | } |
| New file |
| | |
| | | package com.tcm.training.admin.system.service; |
| | | |
| | | import com.baomidou.mybatisplus.core.MybatisConfiguration; |
| | | import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; |
| | | import com.tcm.training.admin.system.dto.AgreementContentSaveRequest; |
| | | import com.tcm.training.admin.system.entity.AgreementContent; |
| | | import com.tcm.training.admin.system.mapper.AgreementContentMapper; |
| | | import com.tcm.training.admin.system.vo.AgreementContentItemVO; |
| | | import org.apache.ibatis.builder.MapperBuilderAssistant; |
| | | import org.junit.jupiter.api.BeforeAll; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.junit.jupiter.api.extension.ExtendWith; |
| | | import org.mockito.ArgumentCaptor; |
| | | import org.mockito.Captor; |
| | | import org.mockito.InjectMocks; |
| | | import org.mockito.Mock; |
| | | import org.mockito.junit.jupiter.MockitoExtension; |
| | | |
| | | import java.util.Collections; |
| | | import java.util.List; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertThrows; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.Mockito.doAnswer; |
| | | import static org.mockito.Mockito.verify; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | @ExtendWith(MockitoExtension.class) |
| | | class AgreementContentAppServiceTest { |
| | | |
| | | @BeforeAll |
| | | static void initLambdaCache() { |
| | | MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); |
| | | TableInfoHelper.initTableInfo(assistant, AgreementContent.class); |
| | | } |
| | | |
| | | @Mock |
| | | private AgreementContentMapper agreementContentMapper; |
| | | |
| | | @InjectMocks |
| | | private AgreementContentAppService agreementContentAppService; |
| | | |
| | | @Captor |
| | | private ArgumentCaptor<AgreementContent> agreementContentCaptor; |
| | | |
| | | @Test |
| | | void listShouldAlwaysReturnThreeAgreements() { |
| | | AgreementContent record = new AgreementContent(); |
| | | record.setAgreementType(AgreementContent.TYPE_PRIVACY_POLICY); |
| | | record.setContentHtml("<p>隐私协议内容</p>"); |
| | | when(agreementContentMapper.selectList(any())).thenReturn(Collections.singletonList(record)); |
| | | |
| | | List<AgreementContentItemVO> result = agreementContentAppService.list(); |
| | | |
| | | assertEquals(3, result.size()); |
| | | assertEquals(AgreementContent.TYPE_USER_AGREEMENT, result.get(0).getAgreementType()); |
| | | assertEquals("", result.get(0).getContentHtml()); |
| | | assertEquals(AgreementContent.TYPE_PRIVACY_POLICY, result.get(1).getAgreementType()); |
| | | assertEquals("<p>隐私协议内容</p>", result.get(1).getContentHtml()); |
| | | assertEquals(AgreementContent.TYPE_INVITE_CODE_INSTRUCTIONS, result.get(2).getAgreementType()); |
| | | } |
| | | |
| | | @Test |
| | | void saveShouldInsertWhenAgreementDoesNotExist() { |
| | | when(agreementContentMapper.selectOne(any())).thenReturn(null); |
| | | doAnswer(invocation -> { |
| | | AgreementContent record = invocation.getArgument(0); |
| | | record.setId(1L); |
| | | return 1; |
| | | }).when(agreementContentMapper).insert(any(AgreementContent.class)); |
| | | |
| | | AgreementContentSaveRequest request = new AgreementContentSaveRequest(); |
| | | request.setContentHtml("<p>用户协议内容</p>"); |
| | | |
| | | AgreementContentItemVO result = agreementContentAppService.save("user_agreement", request); |
| | | |
| | | verify(agreementContentMapper).insert(agreementContentCaptor.capture()); |
| | | assertEquals(AgreementContent.TYPE_USER_AGREEMENT, agreementContentCaptor.getValue().getAgreementType()); |
| | | assertEquals("<p>用户协议内容</p>", agreementContentCaptor.getValue().getContentHtml()); |
| | | assertEquals("用户协议", result.getAgreementName()); |
| | | } |
| | | |
| | | @Test |
| | | void saveShouldUpdateWhenAgreementExists() { |
| | | AgreementContent record = new AgreementContent(); |
| | | record.setId(2L); |
| | | record.setAgreementType(AgreementContent.TYPE_PRIVACY_POLICY); |
| | | record.setContentHtml("<p>旧内容</p>"); |
| | | when(agreementContentMapper.selectOne(any())).thenReturn(record); |
| | | |
| | | AgreementContentSaveRequest request = new AgreementContentSaveRequest(); |
| | | request.setContentHtml("<p>新内容</p>"); |
| | | |
| | | agreementContentAppService.save(AgreementContent.TYPE_PRIVACY_POLICY, request); |
| | | |
| | | verify(agreementContentMapper).updateById(agreementContentCaptor.capture()); |
| | | assertEquals("<p>新内容</p>", agreementContentCaptor.getValue().getContentHtml()); |
| | | } |
| | | |
| | | @Test |
| | | void saveShouldRejectBlankContent() { |
| | | AgreementContentSaveRequest request = new AgreementContentSaveRequest(); |
| | | request.setContentHtml("<p><br/></p>"); |
| | | |
| | | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, |
| | | () -> agreementContentAppService.save(AgreementContent.TYPE_USER_AGREEMENT, request)); |
| | | |
| | | assertEquals("请输入协议内容", exception.getMessage()); |
| | | } |
| | | |
| | | @Test |
| | | void saveShouldRejectIllegalAgreementType() { |
| | | AgreementContentSaveRequest request = new AgreementContentSaveRequest(); |
| | | request.setContentHtml("<p>内容</p>"); |
| | | |
| | | IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, |
| | | () -> agreementContentAppService.save("UNKNOWN", request)); |
| | | |
| | | assertEquals("协议类型不合法", exception.getMessage()); |
| | | } |
| | | } |
| | |
| | | component: './system/platform-qualification', |
| | | }, |
| | | { |
| | | name: '协议内容', |
| | | path: '/system/settings/agreement-content', |
| | | access: 'system:agreement-content:view', |
| | | component: './system/agreement-content', |
| | | }, |
| | | { |
| | | name: '消息推送配置', |
| | | path: '/system/settings/message-push', |
| | | access: 'system:message-push:view', |
| | |
| | | "@types/history": "^4.7.11", |
| | | "@types/jest": "^29.4.0", |
| | | "@types/lodash": "^4.14.191", |
| | | "@types/minimatch": "^5.1.2", |
| | | "@types/react": "^18.0.28", |
| | | "@types/react-dom": "^18.0.11", |
| | | "@types/react-helmet": "^6.1.6", |
| | |
| | | "prettier": "^2.8.4", |
| | | "swagger-ui-dist": "^4.15.5", |
| | | "ts-node": "^10.9.1", |
| | | "typescript": "^4.9.5", |
| | | "typescript": "^5.3.3", |
| | | "umi-presets-pro": "^2.0.2" |
| | | }, |
| | | "engines": { |
| | |
| | | !!permission?.['system:banner:view'] || |
| | | !!permission?.['system:activity-notice:view'] || |
| | | !!permission?.['system:platform-qualification:view'] || |
| | | !!permission?.['system:agreement-content:view'] || |
| | | !!permission?.['system:message-push:view'] || |
| | | !!permission?.['system:points-settings:view'], |
| | | canViewMemberModule: canViewMemberUserGroup || canViewMemberVipGroup, |
| | |
| | | const loginPath = '/login'; |
| | | import '../public/font.css' |
| | | |
| | | type CurrentUser = { |
| | | username?: string; |
| | | }; |
| | | |
| | | type InitialState = { |
| | | settings?: Partial<LayoutSettings>; |
| | | currentUser?: CurrentUser; |
| | | permission?: Record<string, boolean>; |
| | | loading?: boolean; |
| | | }; |
| | | |
| | | |
| | | /** |
| | | * @see https://umijs.org/zh-CN/plugins/plugin-initial-state |
| | | * */ |
| | | export async function getInitialState(): Promise<{ |
| | | settings?: Partial<LayoutSettings>; |
| | | currentUser?: {}; |
| | | permission?: Array<Permissions>; |
| | | loading?: boolean; |
| | | }> { |
| | | export async function getInitialState(): Promise<InitialState> { |
| | | // 如果不是登录页面,执行 |
| | | const { location } = history; |
| | | |
| | |
| | | history.replace(loginPath) |
| | | } |
| | | return { |
| | | permission: decryptData(localStorage.getItem('access')) || [], |
| | | permission: decryptData(localStorage.getItem('access')) || {}, |
| | | currentUser: decryptData(localStorage.getItem('userInfo')) || {}, |
| | | settings: { ...defaultSettings, fixedHeader: true } as Partial<LayoutSettings>, |
| | | }; |
| | |
| | | }, |
| | | }, |
| | | waterMarkProps: { |
| | | content: false, |
| | | content: '', |
| | | }, |
| | | // 自定义 403 页面 |
| | | unAccessible: <div></div>, |
| New file |
| | |
| | | import type React from 'react'; |
| | | |
| | | export type BraftEditorProps = { |
| | | data?: string; |
| | | resultCallback: (html: string) => void; |
| | | readOnly?: boolean; |
| | | }; |
| | | |
| | | declare const BraftEditorCom: React.FC<BraftEditorProps>; |
| | | |
| | | export default BraftEditorCom; |
| New file |
| | |
| | | export type LoginResponse = { |
| | | data: { |
| | | tokenType: string; |
| | | token: string; |
| | | user: { |
| | | username?: string; |
| | | permissionKeys?: string[]; |
| | | [key: string]: unknown; |
| | | }; |
| | | }; |
| | | }; |
| | | |
| | | export type CaptchaResponse = { |
| | | data: { |
| | | captchaId: string; |
| | | imageData: string; |
| | | }; |
| | | }; |
| | | |
| | | export function updatePwd(params: Record<string, unknown>): Promise<unknown>; |
| | | export function sendMessage(params: Record<string, unknown>): Promise<unknown>; |
| | | export function login(data: Record<string, unknown>): Promise<LoginResponse>; |
| | | export function getCaptcha(): Promise<CaptchaResponse>; |
| New file |
| | |
| | | import BraftEditorCom from '@/components/BraftEditor'; |
| | | import { history, useAccess } from '@umijs/max'; |
| | | import { Button, Form, Spin, message } from 'antd'; |
| | | import React, { useEffect, useRef, useState } from 'react'; |
| | | import { getAgreementContentList, saveAgreementContent } from './service'; |
| | | import './style.less'; |
| | | |
| | | type AgreementType = 'USER_AGREEMENT' | 'PRIVACY_POLICY' | 'INVITE_CODE_INSTRUCTIONS'; |
| | | |
| | | type AgreementItem = { |
| | | agreementType: AgreementType; |
| | | agreementName: string; |
| | | contentHtml?: string; |
| | | }; |
| | | |
| | | type AgreementFormValues = { |
| | | contentHtml?: string; |
| | | }; |
| | | |
| | | const AGREEMENT_OPTIONS: AgreementItem[] = [ |
| | | { agreementType: 'USER_AGREEMENT', agreementName: '用户协议', contentHtml: '' }, |
| | | { agreementType: 'PRIVACY_POLICY', agreementName: '隐私协议', contentHtml: '' }, |
| | | { agreementType: 'INVITE_CODE_INSTRUCTIONS', agreementName: '获取邀请码说明', contentHtml: '' }, |
| | | ]; |
| | | |
| | | const isBlankRichText = (html?: string) => { |
| | | const value = (html || '').trim(); |
| | | if (!value) { |
| | | return true; |
| | | } |
| | | const normalized = value |
| | | .replace(/<br\s*\/?>/gi, '') |
| | | .replace(/<\/p>/gi, '') |
| | | .replace(/ /gi, '') |
| | | .replace(/ /gi, '') |
| | | .replace(/<[^>]+>/g, '') |
| | | .trim(); |
| | | if (normalized) { |
| | | return false; |
| | | } |
| | | const lowerCaseValue = value.toLowerCase(); |
| | | return !lowerCaseValue.includes('<img') && !lowerCaseValue.includes('<video'); |
| | | }; |
| | | |
| | | const listToMap = (list: AgreementItem[]) => { |
| | | const initialMap: Record<AgreementType, string> = { |
| | | USER_AGREEMENT: '', |
| | | PRIVACY_POLICY: '', |
| | | INVITE_CODE_INSTRUCTIONS: '', |
| | | }; |
| | | list.forEach((item) => { |
| | | initialMap[item.agreementType] = item.contentHtml || ''; |
| | | }); |
| | | return initialMap; |
| | | }; |
| | | |
| | | const AgreementContentPage: React.FC = () => { |
| | | const access = useAccess(); |
| | | const [form] = Form.useForm<AgreementFormValues>(); |
| | | const [loading, setLoading] = useState(false); |
| | | const [submitting, setSubmitting] = useState(false); |
| | | const [activeType, setActiveType] = useState<AgreementType>('USER_AGREEMENT'); |
| | | const [editorContent, setEditorContent] = useState(''); |
| | | const draftsRef = useRef<Record<AgreementType, string>>({ |
| | | USER_AGREEMENT: '', |
| | | PRIVACY_POLICY: '', |
| | | INVITE_CODE_INSTRUCTIONS: '', |
| | | }); |
| | | |
| | | const canView = access['system:agreement-content:view']; |
| | | const canEdit = access['system:agreement-content:edit']; |
| | | |
| | | const activeLabel = |
| | | AGREEMENT_OPTIONS.find((item) => item.agreementType === activeType)?.agreementName || '协议内容'; |
| | | |
| | | const loadAgreementContents = async () => { |
| | | setLoading(true); |
| | | try { |
| | | const res = await getAgreementContentList(); |
| | | const nextContents = listToMap(res.data || AGREEMENT_OPTIONS); |
| | | draftsRef.current = nextContents; |
| | | setEditorContent(nextContents[activeType] || ''); |
| | | form.setFieldsValue({ contentHtml: nextContents[activeType] || '' }); |
| | | } catch (error) { |
| | | message.error((error as Error)?.message || '协议内容加载失败'); |
| | | draftsRef.current = listToMap(AGREEMENT_OPTIONS); |
| | | setEditorContent(''); |
| | | form.setFieldsValue({ contentHtml: '' }); |
| | | } finally { |
| | | setLoading(false); |
| | | } |
| | | }; |
| | | |
| | | useEffect(() => { |
| | | if (!canView) { |
| | | history.replace('/Welcome'); |
| | | return; |
| | | } |
| | | loadAgreementContents(); |
| | | }, [canView]); |
| | | |
| | | const handleTabChange = (agreementType: AgreementType) => { |
| | | draftsRef.current[activeType] = form.getFieldValue('contentHtml') || ''; |
| | | setActiveType(agreementType); |
| | | const nextContent = draftsRef.current[agreementType] || ''; |
| | | setEditorContent(nextContent); |
| | | form.setFieldsValue({ |
| | | contentHtml: nextContent, |
| | | }); |
| | | }; |
| | | |
| | | const handleEditorChange = (html: string) => { |
| | | form.setFieldsValue({ contentHtml: html }); |
| | | draftsRef.current[activeType] = html; |
| | | }; |
| | | |
| | | const handleSave = async () => { |
| | | if (!canEdit || submitting) { |
| | | return; |
| | | } |
| | | const values = await form.validateFields(); |
| | | if (isBlankRichText(values.contentHtml)) { |
| | | form.setFields([ |
| | | { |
| | | name: 'contentHtml', |
| | | errors: ['请输入协议内容'], |
| | | }, |
| | | ]); |
| | | return; |
| | | } |
| | | setSubmitting(true); |
| | | try { |
| | | const payload = { |
| | | contentHtml: values.contentHtml?.trim(), |
| | | }; |
| | | const res = await saveAgreementContent(activeType, payload); |
| | | const savedContent = res.data?.contentHtml || payload.contentHtml || ''; |
| | | draftsRef.current[activeType] = savedContent; |
| | | setEditorContent(savedContent); |
| | | form.setFieldsValue({ contentHtml: savedContent }); |
| | | message.success('保存成功'); |
| | | } catch (error) { |
| | | message.error((error as Error)?.message || `${activeLabel}保存失败,请稍后重试`); |
| | | } finally { |
| | | setSubmitting(false); |
| | | } |
| | | }; |
| | | |
| | | return ( |
| | | <div className="agreement-content-page"> |
| | | <div className="agreement-content-card"> |
| | | <div className="agreement-content-card__title">协议内容</div> |
| | | <div className="agreement-content-tabs"> |
| | | {AGREEMENT_OPTIONS.map((item) => ( |
| | | <button |
| | | key={item.agreementType} |
| | | type="button" |
| | | className={`agreement-content-tabs__item${ |
| | | item.agreementType === activeType ? ' agreement-content-tabs__item--active' : '' |
| | | }`} |
| | | onClick={() => handleTabChange(item.agreementType)} |
| | | > |
| | | {item.agreementName} |
| | | </button> |
| | | ))} |
| | | </div> |
| | | <Spin spinning={loading}> |
| | | <Form form={form} layout="vertical" disabled={loading || !canEdit}> |
| | | <Form.Item |
| | | name="contentHtml" |
| | | rules={[ |
| | | { |
| | | validator: async (_, value) => { |
| | | if (isBlankRichText(value)) { |
| | | throw new Error('请输入协议内容'); |
| | | } |
| | | }, |
| | | }, |
| | | ]} |
| | | > |
| | | <div className="agreement-content-editor"> |
| | | <BraftEditorCom |
| | | key={activeType} |
| | | data={editorContent} |
| | | readOnly={!canEdit} |
| | | resultCallback={handleEditorChange} |
| | | /> |
| | | </div> |
| | | </Form.Item> |
| | | </Form> |
| | | </Spin> |
| | | <div className="agreement-content-footer"> |
| | | <Button type="primary" onClick={handleSave} loading={submitting} disabled={!canEdit}> |
| | | 保存 |
| | | </Button> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | ); |
| | | }; |
| | | |
| | | export default AgreementContentPage; |
| New file |
| | |
| | | import { request } from '@umijs/max'; |
| | | |
| | | export async function getAgreementContentList() { |
| | | return request('/agreement-contents', { |
| | | method: 'GET', |
| | | }); |
| | | } |
| | | |
| | | export async function saveAgreementContent( |
| | | agreementType: string, |
| | | data: Record<string, any>, |
| | | ) { |
| | | return request(`/agreement-contents/${agreementType}`, { |
| | | method: 'PUT', |
| | | data, |
| | | }); |
| | | } |
| New file |
| | |
| | | .agreement-content-page { |
| | | min-height: calc(100vh - 48px); |
| | | padding: 18px 20px 24px; |
| | | background: #f2f5f9; |
| | | } |
| | | |
| | | .agreement-content-card { |
| | | min-height: 700px; |
| | | padding: 18px 18px 24px; |
| | | background: #fff; |
| | | border-radius: 10px; |
| | | box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04); |
| | | } |
| | | |
| | | .agreement-content-card__title { |
| | | margin: 0 0 22px; |
| | | color: #303133; |
| | | font-weight: 500; |
| | | font-size: 18px; |
| | | } |
| | | |
| | | .agreement-content-tabs { |
| | | display: inline-flex; |
| | | margin-bottom: 28px; |
| | | overflow: hidden; |
| | | border: 1px solid #2f86f6; |
| | | border-radius: 4px; |
| | | } |
| | | |
| | | .agreement-content-tabs__item { |
| | | min-width: 166px; |
| | | height: 32px; |
| | | color: #2f86f6; |
| | | background: #fff; |
| | | border: none; |
| | | border-right: 1px solid #2f86f6; |
| | | cursor: pointer; |
| | | } |
| | | |
| | | .agreement-content-tabs__item:last-child { |
| | | border-right: none; |
| | | } |
| | | |
| | | .agreement-content-tabs__item--active { |
| | | color: #fff; |
| | | background: #2f86f6; |
| | | } |
| | | |
| | | .agreement-content-editor { |
| | | margin-bottom: 28px; |
| | | border: 1px solid #dcdfe6; |
| | | } |
| | | |
| | | .agreement-content-editor .DraftEditor-root { |
| | | min-height: 420px; |
| | | } |
| | | |
| | | .agreement-content-footer { |
| | | display: flex; |
| | | justify-content: flex-start; |
| | | } |
| | | |
| | | @media (max-width: 900px) { |
| | | .agreement-content-page { |
| | | padding: 12px; |
| | | } |
| | | |
| | | .agreement-content-card { |
| | | min-height: auto; |
| | | padding: 16px; |
| | | } |
| | | |
| | | .agreement-content-tabs { |
| | | display: flex; |
| | | width: 100%; |
| | | } |
| | | |
| | | .agreement-content-tabs__item { |
| | | flex: 1; |
| | | min-width: 0; |
| | | padding: 0 8px; |
| | | font-size: 12px; |
| | | } |
| | | } |
| | |
| | | history.replace('/system/settings/platform-qualification'); |
| | | return; |
| | | } |
| | | if (access['system:agreement-content:view']) { |
| | | history.replace('/system/settings/agreement-content'); |
| | | return; |
| | | } |
| | | if (access['system:message-push:view']) { |
| | | history.replace('/system/settings/message-push'); |
| | | return; |
| New file |
| | |
| | | export function outLogin(): Promise<unknown>; |
| New file |
| | |
| | | export function customRequest(params: Record<string, unknown>, type?: number): void; |
| | | export function exportExcell( |
| | | name: string, |
| | | params: Record<string, unknown>, |
| | | url: string, |
| | | ): void; |
| | | export function downLoad(url: string, name: string): void; |
| | | export function downloadFile(url: string, fileName: string): Promise<void> | void; |
| | | export function encryptData(data: unknown): string; |
| | | export function decryptData(cipherText?: string | null): any; |
| New file |
| | |
| | | declare var BASE_URL: string; |