chore(merge): 合并 App 版本管理后端
This commit is contained in:
commit
47f19a98eb
8 files changed
+472
No files matched your search
+85
@@ -0,0 +1,85 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.admin.controller;
|
||||
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
import com.fruitshorts.system.service.AppVersionService;
|
||||
import com.fruitshorts.system.vo.AppVersionQueryReqVO;
|
||||
import com.fruitshorts.system.vo.AppVersionRespVO;
|
||||
import com.fruitshorts.system.vo.AppVersionSaveReqVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 后台 - App 版本管理。 */
|
||||
@Tag(name = "后台-App 版本")
|
||||
@RestController
|
||||
@RequestMapping("/admin/app-versions")
|
||||
@RequiredArgsConstructor
|
||||
public class AppVersionAdminController {
|
||||
|
||||
private final AppVersionService appVersionService;
|
||||
|
||||
@Operation(summary = "版本分页查询")
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("@ss.hasPermission('app-version:list')")
|
||||
public Result<PageResult<AppVersionRespVO>> page(@Validated AppVersionQueryReqVO req) {
|
||||
return Result.ok(appVersionService.page(req));
|
||||
}
|
||||
|
||||
@Operation(summary = "版本详情")
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermission('app-version:list')")
|
||||
public Result<AppVersionRespVO> detail(@PathVariable Long id) {
|
||||
return Result.ok(appVersionService.detail(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "新建版本(默认发布)")
|
||||
@PostMapping
|
||||
@PreAuthorize("@ss.hasPermission('app-version:create')")
|
||||
@OperationLog(module = "版本管理", action = "新建版本")
|
||||
public Result<Long> create(@Valid @RequestBody AppVersionSaveReqVO req) {
|
||||
return Result.ok(appVersionService.create(req));
|
||||
}
|
||||
|
||||
@Operation(summary = "编辑版本")
|
||||
@PutMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermission('app-version:update')")
|
||||
@OperationLog(module = "版本管理", action = "编辑版本")
|
||||
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody AppVersionSaveReqVO req) {
|
||||
appVersionService.update(id, req);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "发布 / 下架")
|
||||
@PutMapping("/{id}/status")
|
||||
@PreAuthorize("@ss.hasPermission('app-version:update')")
|
||||
@OperationLog(module = "版本管理", action = "版本上下架")
|
||||
public Result<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) {
|
||||
appVersionService.updateStatus(id, status);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "删除版本")
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("@ss.hasPermission('app-version:delete')")
|
||||
@OperationLog(module = "版本管理", action = "删除版本")
|
||||
public Result<Void> delete(@PathVariable Long id) {
|
||||
appVersionService.delete(id);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.system.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* App 版本(app_version)。
|
||||
*
|
||||
* <p>iOS / Android 共用一张表,由 {@code platform} 区分;同一平台下 {@code version_code}
|
||||
* 作为整数比较用(C 端用它判断是否需要升级),{@code version_name} 是展示用字符串。
|
||||
*/
|
||||
@Data
|
||||
@TableName("app_version")
|
||||
public class AppVersion {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
/** ANDROID / IOS。 */
|
||||
private String platform;
|
||||
|
||||
/** 整数版本号(比较用)。 */
|
||||
private Integer versionCode;
|
||||
|
||||
/** 展示版本号,如 1.2.0。 */
|
||||
private String versionName;
|
||||
|
||||
/** 下载链接(Android apk 或 iOS App Store)。 */
|
||||
private String downloadUrl;
|
||||
|
||||
/** 更新说明。 */
|
||||
private String updateContent;
|
||||
|
||||
/** 1=强制升级,0=可选升级。 */
|
||||
private Integer forceUpdate;
|
||||
|
||||
/** 低于此版本强制升级(兜底)。 */
|
||||
private Integer minSupportedCode;
|
||||
|
||||
/** 1=已发布,0=已下架。 */
|
||||
private Integer status;
|
||||
|
||||
private LocalDateTime releasedAt;
|
||||
private Long createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.system.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.system.dal.entity.AppVersion;
|
||||
|
||||
/** AppVersion Mapper。 */
|
||||
public interface AppVersionMapper extends BaseMapper<AppVersion> {
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.system.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.system.dal.entity.AppVersion;
|
||||
import com.fruitshorts.system.dal.mapper.AppVersionMapper;
|
||||
import com.fruitshorts.system.vo.AppVersionQueryReqVO;
|
||||
import com.fruitshorts.system.vo.AppVersionRespVO;
|
||||
import com.fruitshorts.system.vo.AppVersionSaveReqVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* App 版本管理:后台 CRUD + 按平台查询最新发布版。
|
||||
*
|
||||
* <p>同一平台下 {@code version_code} 唯一(按 (platform, version_code) 校验重复)。
|
||||
* 发布(status=1)即对 C 端可见,下架后 C 端 latest 查询不再返回。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AppVersionService {
|
||||
|
||||
private static final List<String> PLATFORMS = List.of("ANDROID", "IOS");
|
||||
|
||||
private final AppVersionMapper versionMapper;
|
||||
|
||||
public PageResult<AppVersionRespVO> page(AppVersionQueryReqVO req) {
|
||||
Page<AppVersion> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
|
||||
String platform = normalizedPlatform(req.getPlatform());
|
||||
IPage<AppVersion> result = versionMapper.selectPage(page, Wrappers.<AppVersion>lambdaQuery()
|
||||
.eq(req.getStatus() != null, AppVersion::getStatus, req.getStatus())
|
||||
.eq(platform != null, AppVersion::getPlatform, platform)
|
||||
.like(StringUtils.hasText(req.getKeyword()), AppVersion::getVersionName, req.getKeyword())
|
||||
.orderByDesc(AppVersion::getVersionCode)
|
||||
.orderByDesc(AppVersion::getId));
|
||||
return new PageResult<>(result.getTotal(),
|
||||
result.getRecords().stream().map(AppVersionRespVO::of).toList());
|
||||
}
|
||||
|
||||
public AppVersionRespVO detail(Long id) {
|
||||
return AppVersionRespVO.of(getExisting(id));
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long create(AppVersionSaveReqVO req) {
|
||||
String platform = normalizePlatform(req.getPlatform());
|
||||
checkDuplicate(platform, req.getVersionCode(), null);
|
||||
AppVersion v = new AppVersion();
|
||||
applyFields(v, req, platform);
|
||||
v.setStatus(1);
|
||||
v.setReleasedAt(LocalDateTime.now());
|
||||
versionMapper.insert(v);
|
||||
return v.getId();
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void update(Long id, AppVersionSaveReqVO req) {
|
||||
String platform = normalizePlatform(req.getPlatform());
|
||||
AppVersion existing = getExisting(id);
|
||||
checkDuplicate(platform, req.getVersionCode(), id);
|
||||
applyFields(existing, req, platform);
|
||||
versionMapper.updateById(existing);
|
||||
}
|
||||
|
||||
/** 发布(1)/ 下架(0)。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateStatus(Long id, Integer status) {
|
||||
if (status == null || (status != 0 && status != 1)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "状态只能是 0(下架)或 1(发布)");
|
||||
}
|
||||
AppVersion v = getExisting(id);
|
||||
v.setStatus(status);
|
||||
if (status == 1 && v.getReleasedAt() == null) {
|
||||
v.setReleasedAt(LocalDateTime.now());
|
||||
}
|
||||
versionMapper.updateById(v);
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delete(Long id) {
|
||||
getExisting(id);
|
||||
versionMapper.deleteById(id);
|
||||
}
|
||||
|
||||
/** C 端用:某平台最新已发布版本(版本码最大者)。 */
|
||||
public AppVersion latestFor(String platform) {
|
||||
String normalized = normalizePlatform(platform);
|
||||
return versionMapper.selectOne(Wrappers.<AppVersion>lambdaQuery()
|
||||
.eq(AppVersion::getPlatform, normalized)
|
||||
.eq(AppVersion::getStatus, 1)
|
||||
.orderByDesc(AppVersion::getVersionCode)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
// ─────────── 内部 ───────────
|
||||
|
||||
private void applyFields(AppVersion v, AppVersionSaveReqVO req, String platform) {
|
||||
v.setPlatform(platform);
|
||||
v.setVersionCode(req.getVersionCode());
|
||||
v.setVersionName(req.getVersionName().trim());
|
||||
v.setDownloadUrl(req.getDownloadUrl().trim());
|
||||
v.setUpdateContent(StringUtils.hasText(req.getUpdateContent()) ? req.getUpdateContent().trim() : "");
|
||||
v.setForceUpdate(req.getForceUpdate() == null ? 0 : req.getForceUpdate());
|
||||
v.setMinSupportedCode(req.getMinSupportedCode() == null ? 0 : req.getMinSupportedCode());
|
||||
}
|
||||
|
||||
private String normalizePlatform(String platform) {
|
||||
if (!StringUtils.hasText(platform)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "请选择平台");
|
||||
}
|
||||
String normalized = platform.trim().toUpperCase();
|
||||
if (!PLATFORMS.contains(normalized)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST,
|
||||
"平台取值不合法:" + platform + ",可选 ANDROID / IOS");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String normalizedPlatform(String platform) {
|
||||
if (!StringUtils.hasText(platform)) return null;
|
||||
return normalizePlatform(platform);
|
||||
}
|
||||
|
||||
/** 同平台下 version_code 不可重复(update 时排除自身 id)。 */
|
||||
private void checkDuplicate(String platform, Integer versionCode, Long excludeId) {
|
||||
Long count = versionMapper.selectCount(Wrappers.<AppVersion>lambdaQuery()
|
||||
.eq(AppVersion::getPlatform, platform)
|
||||
.eq(AppVersion::getVersionCode, versionCode)
|
||||
.ne(excludeId != null, AppVersion::getId, excludeId));
|
||||
if (count != null && count > 0) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST,
|
||||
"该平台下 versionCode=" + versionCode + " 已存在");
|
||||
}
|
||||
}
|
||||
|
||||
private AppVersion getExisting(Long id) {
|
||||
AppVersion v = id == null ? null : versionMapper.selectById(id);
|
||||
if (v == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "版本不存在");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.system.vo;
|
||||
|
||||
import com.fruitshorts.common.pojo.PageParam;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/** App 版本后台分页查询。 */
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AppVersionQueryReqVO extends PageParam {
|
||||
|
||||
/** ANDROID / IOS,空=全部。 */
|
||||
private String platform;
|
||||
|
||||
/** 1=已发布 / 0=已下架,空=全部。 */
|
||||
private Integer status;
|
||||
|
||||
/** 关键字(匹配 version_name)。 */
|
||||
private String keyword;
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.system.vo;
|
||||
|
||||
import com.fruitshorts.system.dal.entity.AppVersion;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** App 版本后台响应。 */
|
||||
@Data
|
||||
public class AppVersionRespVO {
|
||||
|
||||
private Long id;
|
||||
private String platform;
|
||||
private String platformText;
|
||||
private Integer versionCode;
|
||||
private String versionName;
|
||||
private String downloadUrl;
|
||||
private String updateContent;
|
||||
private Integer forceUpdate;
|
||||
private Integer minSupportedCode;
|
||||
private Integer status;
|
||||
private String statusText;
|
||||
private LocalDateTime releasedAt;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public static AppVersionRespVO of(AppVersion v) {
|
||||
AppVersionRespVO vo = new AppVersionRespVO();
|
||||
vo.setId(v.getId());
|
||||
vo.setPlatform(v.getPlatform());
|
||||
vo.setPlatformText(platformText(v.getPlatform()));
|
||||
vo.setVersionCode(v.getVersionCode());
|
||||
vo.setVersionName(v.getVersionName());
|
||||
vo.setDownloadUrl(v.getDownloadUrl());
|
||||
vo.setUpdateContent(v.getUpdateContent());
|
||||
vo.setForceUpdate(v.getForceUpdate());
|
||||
vo.setMinSupportedCode(v.getMinSupportedCode());
|
||||
vo.setStatus(v.getStatus());
|
||||
vo.setStatusText(v.getStatus() != null && v.getStatus() == 1 ? "已发布" : "已下架");
|
||||
vo.setReleasedAt(v.getReleasedAt());
|
||||
vo.setCreatedAt(v.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private static String platformText(String p) {
|
||||
return switch (p == null ? "" : p.toUpperCase()) {
|
||||
case "ANDROID" -> "Android";
|
||||
case "IOS" -> "iOS";
|
||||
default -> p == null ? "-" : p;
|
||||
};
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.system.vo;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/** App 版本新增/编辑。 */
|
||||
@Data
|
||||
public class AppVersionSaveReqVO {
|
||||
|
||||
/** ANDROID / IOS。 */
|
||||
@NotBlank(message = "请选择平台")
|
||||
private String platform;
|
||||
|
||||
@NotNull(message = "请填写整数版本号")
|
||||
@Min(value = 1, message = "versionCode 必须大于 0")
|
||||
@Max(value = 999999, message = "versionCode 过大")
|
||||
private Integer versionCode;
|
||||
|
||||
@NotBlank(message = "请填写展示版本号")
|
||||
@Size(max = 32, message = "versionName 过长")
|
||||
private String versionName;
|
||||
|
||||
@NotBlank(message = "请填写下载地址")
|
||||
@Size(max = 512, message = "下载地址过长")
|
||||
private String downloadUrl;
|
||||
|
||||
@Size(max = 2000, message = "更新说明过长")
|
||||
private String updateContent;
|
||||
|
||||
/** 0=可选(默认),1=强制升级。 */
|
||||
private Integer forceUpdate;
|
||||
|
||||
/** 低于此版本强制升级(兜底,默认 0 不开启)。 */
|
||||
private Integer minSupportedCode;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Author: fruit.com.cn
|
||||
-- App 版本管理:表 + 后台菜单 + 角色菜单授权
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_version (
|
||||
id BIGINT PRIMARY KEY,
|
||||
platform VARCHAR(16) NOT NULL, -- ANDROID / IOS
|
||||
version_code INTEGER NOT NULL, -- 整数版本号(比较用)
|
||||
version_name VARCHAR(32) NOT NULL, -- 展示版本号,如 1.2.0
|
||||
download_url VARCHAR(512) NOT NULL,
|
||||
update_content TEXT, -- 更新说明
|
||||
force_update SMALLINT NOT NULL DEFAULT 0, -- 1=强更 0=可选
|
||||
min_supported_code INTEGER NOT NULL DEFAULT 0, -- 低于此版本强制升级
|
||||
status SMALLINT NOT NULL DEFAULT 1, -- 1=已发布 0=已下架
|
||||
released_at TIMESTAMP(3),
|
||||
created_by BIGINT,
|
||||
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted SMALLINT NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_version_platform ON app_version(platform, version_code DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_version_status ON app_version(status, platform);
|
||||
DROP TRIGGER IF EXISTS trg_app_version_updated_at ON app_version;
|
||||
CREATE TRIGGER trg_app_version_updated_at
|
||||
BEFORE UPDATE ON app_version
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- 菜单:后台管理下新增「版本管理」(id=39)
|
||||
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, permission_code, type, always_show, affix, sort, visible, status, created_at, updated_at, super_admin_visible)
|
||||
VALUES (39, 6, '版本管理', 'app-versions', 'app-versions', 'views/systemManagement/AppVersionView', '', 'ep:download', 'app-version:list', 'MENU', 0, 0, 9, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 权限按钮
|
||||
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, permission_code, type, sort, visible, status, created_at, updated_at, super_admin_visible) VALUES
|
||||
(40, 39, '查看', '', '', '', 'app-version:list', 'BUTTON', 1, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
|
||||
(41, 39, '新增', '', '', '', 'app-version:create', 'BUTTON', 2, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
|
||||
(42, 39, '编辑', '', '', '', 'app-version:update', 'BUTTON', 3, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
|
||||
(43, 39, '删除', '', '', '', 'app-version:delete', 'BUTTON', 4, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1),
|
||||
(44, 39, '上下架', '', '', '', 'app-version:update', 'BUTTON', 5, 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- 超管角色授权(sys_role_menu 需要手动 id,按现有 MAX(id) 顺延)
|
||||
INSERT INTO sys_role_menu (id, role_id, menu_id)
|
||||
SELECT 5000031, 1, 39 UNION ALL
|
||||
SELECT 5000032, 1, 40 UNION ALL
|
||||
SELECT 5000033, 1, 41 UNION ALL
|
||||
SELECT 5000034, 1, 42 UNION ALL
|
||||
SELECT 5000035, 1, 43 UNION ALL
|
||||
SELECT 5000036, 1, 44
|
||||
ON CONFLICT DO NOTHING;
|
||||
Reference in new issue
Block a user