Commit 7f6048a8 by 真的三个金的鑫

Merge remote-tracking branch 'origin/hk' into hk

parents 613dd868 1181476f
package com.dji.sdk.cloudapi.livestream;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* Response output for live_start_push services_reply.
* Newer DJI firmware returns {"origin_video_id": []} in the output field.
*/
public class LiveStartPushResponse {
@JsonProperty("origin_video_id")
private List<String> originVideoId;
public LiveStartPushResponse() {
}
public List<String> getOriginVideoId() {
return originVideoId;
}
public LiveStartPushResponse setOriginVideoId(List<String> originVideoId) {
this.originVideoId = originVideoId;
return this;
}
}
...@@ -52,9 +52,9 @@ public abstract class AbstractLivestreamService { ...@@ -52,9 +52,9 @@ public abstract class AbstractLivestreamService {
* @param request data * @param request data
* @return services_reply * @return services_reply
*/ */
public TopicServicesResponse<ServicesReplyData<String>> liveStartPush(GatewayManager gateway, LiveStartPushRequest request) { public TopicServicesResponse<ServicesReplyData<LiveStartPushResponse>> liveStartPush(GatewayManager gateway, LiveStartPushRequest request) {
return servicesPublish.publish( return servicesPublish.publish(
new TypeReference<String>() {}, new TypeReference<LiveStartPushResponse>() {},
gateway.getGatewaySn(), gateway.getGatewaySn(),
LiveStreamMethodEnum.LIVE_START_PUSH.getMethod(), LiveStreamMethodEnum.LIVE_START_PUSH.getMethod(),
request, request,
......
...@@ -105,6 +105,22 @@ ...@@ -105,6 +105,22 @@
</excludes> </excludes>
</configuration> </configuration>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.10.1</version> <!-- JDK 11 推荐用 3.8+ 版本 -->
<configuration>
<!-- 统一为 JDK 11,消除版本不匹配警告 -->
<release>11</release>
<encoding>UTF-8</encoding>
<!-- 可选:关闭严格泛型检查(避免额外报错) -->
<compilerArgs>
<arg>-Xlint:-unchecked</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins> </plugins>
</build> </build>
</project> </project>
...@@ -18,6 +18,12 @@ import org.springframework.web.bind.annotation.*; ...@@ -18,6 +18,12 @@ import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.validation.Valid; import javax.validation.Valid;
import com.dji.sample.ai.model.dto.TopicAiInfoDTO;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import static com.dji.sample.component.AuthInterceptor.TOKEN_CLAIM; import static com.dji.sample.component.AuthInterceptor.TOKEN_CLAIM;
...@@ -35,6 +41,9 @@ public class AiInfoController { ...@@ -35,6 +41,9 @@ public class AiInfoController {
@Autowired @Autowired
private IAiInfoService aiInfoService; private IAiInfoService aiInfoService;
@Autowired
private ObjectMapper objectMapper;
/** /**
* Paging to query all users in a workspace. * Paging to query all users in a workspace.
* @param param param * @param param param
...@@ -50,4 +59,53 @@ public class AiInfoController { ...@@ -50,4 +59,53 @@ public class AiInfoController {
return HttpResultResponse.success(paginationData); return HttpResultResponse.success(paginationData);
} }
@PostMapping("/add")
public HttpResultResponse<AiInfoDTO> addAiInfo(@RequestBody @Valid AiInfoDTO aiInfoDTO) {
AiInfoDTO dto = aiInfoService.createAiInfo(aiInfoDTO);
return HttpResultResponse.success(dto);
}
@PostMapping("/addFromTopic")
public HttpResultResponse<AiInfoDTO> addAiInfoFromTopic(@RequestBody TopicAiInfoDTO topicDto) throws Exception {
AiInfoDTO dto = new AiInfoDTO();
dto.setDeviceSn(topicDto.getDeviceSn());
dto.setPayloadSn(topicDto.getPayloadSn());
dto.setWarnType(topicDto.getWarnType());
dto.setWarnEvent(topicDto.getWarnEvent());
dto.setImageUrl(topicDto.getImageUrl());
dto.setWarnLocation(topicDto.getWarnLocation());
dto.setAlgorithmType(topicDto.getAlgorithmType());
if (topicDto.getWarnTime() != null) {
dto.setWarnTime(LocalDateTime.ofInstant(Instant.ofEpochMilli(topicDto.getWarnTime()), ZoneId.systemDefault()));
}
dto.setCreateTime(LocalDateTime.now());
if (topicDto.getObj() != null) {
dto.setJson(objectMapper.writeValueAsString(topicDto.getObj()));
}
AiInfoDTO result = aiInfoService.createAiInfo(dto);
return HttpResultResponse.success(result);
}
@GetMapping("/get")
public HttpResultResponse<AiInfoDTO> getAiInfo(@RequestParam Long id) {
AiInfoDTO dto = aiInfoService.getAiInfoById(id);
return HttpResultResponse.success(dto);
}
@PostMapping("/update")
public HttpResultResponse<Boolean> updateAiInfo(@RequestBody @Valid AiInfoDTO aiInfoDTO) {
boolean ok = aiInfoService.updateAiInfo(aiInfoDTO);
return HttpResultResponse.success(ok);
}
@PostMapping("/delete")
public HttpResultResponse<Boolean> deleteAiInfo(@RequestParam Long id) {
boolean ok = aiInfoService.deleteAiInfoById(id);
return HttpResultResponse.success(ok);
}
} }
package com.dji.sample.ai.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.io.Serializable;
/**
* @author guan
*/
@Data
public class TopicAiInfoDTO implements Serializable {
@JsonProperty("device_sn")
private String deviceSn;
@JsonProperty("payload_sn")
private String payloadSn;
@JsonProperty("warn_type")
private String warnType;
@JsonProperty("warn_event")
private String warnEvent;
/** epoch milli */
@JsonProperty("warn_time")
private Long warnTime;
@JsonProperty("image_url")
private String imageUrl;
@JsonProperty("warn_location")
private String warnLocation;
@JsonProperty("algorithm_type")
private String algorithmType;
/** Raw object payload (may be JSON object) */
@JsonProperty("obj")
private Object obj;
}
...@@ -13,4 +13,12 @@ public interface IAiInfoService extends IService<AiInfoEntity> { ...@@ -13,4 +13,12 @@ public interface IAiInfoService extends IService<AiInfoEntity> {
PaginationData<AiInfoDTO> getAiInfoPageByParam(AiInfoSearchParam param, Long page, Long pageSize); PaginationData<AiInfoDTO> getAiInfoPageByParam(AiInfoSearchParam param, Long page, Long pageSize);
AiInfoDTO createAiInfo(AiInfoDTO dto);
AiInfoDTO getAiInfoById(Long id);
boolean updateAiInfo(AiInfoDTO dto);
boolean deleteAiInfoById(Long id);
} }
...@@ -56,9 +56,71 @@ public class AiInfoServiceImpl extends ServiceImpl<IAiInfoMapper, AiInfoEntity> ...@@ -56,9 +56,71 @@ public class AiInfoServiceImpl extends ServiceImpl<IAiInfoMapper, AiInfoEntity>
String[] arr = param.getOrderBy().split(" "); String[] arr = param.getOrderBy().split(" ");
String column = arr[0]; String column = arr[0];
String desc = arr.length > 1 ? arr[1] : "desc"; String direction = arr.length > 1 ? arr[1] : "desc";
wrapper.last(Objects.nonNull(param.getOrderBy()), " order by " + column + " " + desc); // 避免SQL注入,不直接使用SQL语句,所以判断相应的列
switch (column) {
case "id":
if ("asc".equals(direction)) {
wrapper.orderByAsc(AiInfoEntity::getId);
} else {
wrapper.orderByDesc(AiInfoEntity::getId);
}
break;
case "createTime":
case "create_time":
if ("asc".equals(direction)) {
wrapper.orderByAsc(AiInfoEntity::getCreateTime);
} else {
wrapper.orderByDesc(AiInfoEntity::getCreateTime);
}
break;
case "warnTime":
case "warn_time":
if ("asc".equals(direction)) {
wrapper.orderByAsc(AiInfoEntity::getWarnTime);
} else {
wrapper.orderByDesc(AiInfoEntity::getWarnTime);
}
break;
case "deviceSn":
case "device_sn":
if ("asc".equals(direction)) {
wrapper.orderByAsc(AiInfoEntity::getDeviceSn);
} else {
wrapper.orderByDesc(AiInfoEntity::getDeviceSn);
}
break;
case "warnType":
case "warn_type":
if ("asc".equals(direction)) {
wrapper.orderByAsc(AiInfoEntity::getWarnType);
} else {
wrapper.orderByDesc(AiInfoEntity::getWarnType);
}
break;
case "warnEvent":
case "warn_event":
if ("asc".equals(direction)) {
wrapper.orderByAsc(AiInfoEntity::getWarnEvent);
} else {
wrapper.orderByDesc(AiInfoEntity::getWarnEvent);
}
break;
case "algorithmType":
case "algorithm_type":
if ("asc".equals(direction)) {
wrapper.orderByAsc(AiInfoEntity::getAlgorithmType);
} else {
wrapper.orderByDesc(AiInfoEntity::getAlgorithmType);
}
break;
default:
wrapper.orderByDesc(AiInfoEntity::getId);
break;
}
} else {
wrapper.orderByDesc(AiInfoEntity::getId);
} }
Page<AiInfoEntity> pagination = this.page(new Page<>(page, pageSize), wrapper); Page<AiInfoEntity> pagination = this.page(new Page<>(page, pageSize), wrapper);
...@@ -112,4 +174,28 @@ public class AiInfoServiceImpl extends ServiceImpl<IAiInfoMapper, AiInfoEntity> ...@@ -112,4 +174,28 @@ public class AiInfoServiceImpl extends ServiceImpl<IAiInfoMapper, AiInfoEntity>
return aiInfoEntity; return aiInfoEntity;
} }
@Override
public AiInfoDTO createAiInfo(AiInfoDTO dto) {
AiInfoEntity entity = AiInfoDTOToEntity(dto);
this.save(entity);
return AiInfoEntityToDTO(entity);
}
@Override
public AiInfoDTO getAiInfoById(Long id) {
AiInfoEntity entity = this.getById(id);
return Objects.nonNull(entity) ? AiInfoEntityToDTO(entity) : null;
}
@Override
public boolean updateAiInfo(AiInfoDTO dto) {
AiInfoEntity entity = AiInfoDTOToEntity(dto);
return this.updateById(entity);
}
@Override
public boolean deleteAiInfoById(Long id) {
return this.removeById(id);
}
} }
...@@ -39,7 +39,7 @@ public class JwtUtil { ...@@ -39,7 +39,7 @@ public class JwtUtil {
@Value("${jwt.age: 86400}") @Value("${jwt.age: 86400}")
private void setAge(long age) { private void setAge(long age) {
JwtUtil.age = age * 1000; JwtUtil.age = age;
} }
@Value("${jwt.secret: CloudApiSample}") @Value("${jwt.secret: CloudApiSample}")
......
...@@ -32,19 +32,20 @@ public class AuthPrincipalHandler extends DefaultHandshakeHandler { ...@@ -32,19 +32,20 @@ public class AuthPrincipalHandler extends DefaultHandshakeHandler {
HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest(); HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
String token = servletRequest.getParameter(AuthInterceptor.PARAM_TOKEN); String token = servletRequest.getParameter(AuthInterceptor.PARAM_TOKEN);
// 默认让WebSocket的认证都通过
if (!StringUtils.hasText(token)) { if (!StringUtils.hasText(token)) {
return false; return true;
} }
log.debug("token:" + token); log.debug("token:" + token);
Optional<CustomClaim> customClaim = JwtUtil.parseToken(token); Optional<CustomClaim> customClaim = JwtUtil.parseToken(token);
if (customClaim.isEmpty()) { if (customClaim.isEmpty()) {
return false; return true;
} }
servletRequest.setAttribute(AuthInterceptor.TOKEN_CLAIM, customClaim.get()); servletRequest.setAttribute(AuthInterceptor.TOKEN_CLAIM, customClaim.get());
return true; return true;
} }
return false; return true;
} }
...@@ -63,6 +64,10 @@ public class AuthPrincipalHandler extends DefaultHandshakeHandler { ...@@ -63,6 +64,10 @@ public class AuthPrincipalHandler extends DefaultHandshakeHandler {
CustomClaim claim = (CustomClaim) ((ServletServerHttpRequest) request).getServletRequest() CustomClaim claim = (CustomClaim) ((ServletServerHttpRequest) request).getServletRequest()
.getAttribute(AuthInterceptor.TOKEN_CLAIM); .getAttribute(AuthInterceptor.TOKEN_CLAIM);
if (claim == null) {
return () -> null;
}
return () -> claim.getWorkspaceId() + "/" + claim.getUserType() + "/" + claim.getId(); return () -> claim.getWorkspaceId() + "/" + claim.getUserType() + "/" + claim.getId();
} }
return () -> null; return () -> null;
......
package com.dji.sample.component.websocket.config; package com.dji.sample.component.websocket.config;
import com.dji.sample.common.model.CustomClaim;
import com.dji.sample.common.util.JwtUtil;
import com.dji.sample.component.websocket.service.IWebSocketManageService; import com.dji.sample.component.websocket.service.IWebSocketManageService;
import com.dji.sdk.websocket.WebSocketDefaultHandler; import com.dji.sdk.websocket.WebSocketDefaultHandler;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.CloseStatus;
...@@ -10,6 +14,9 @@ import org.springframework.web.socket.WebSocketMessage; ...@@ -10,6 +14,9 @@ import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.WebSocketSession;
import java.security.Principal; import java.security.Principal;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/** /**
* *
...@@ -22,6 +29,11 @@ public class MyWebSocketHandler extends WebSocketDefaultHandler { ...@@ -22,6 +29,11 @@ public class MyWebSocketHandler extends WebSocketDefaultHandler {
private IWebSocketManageService webSocketManageService; private IWebSocketManageService webSocketManageService;
private ObjectMapper objectMapper = new ObjectMapper();
private Map<String, Boolean> authenticatedSessions = new ConcurrentHashMap<>();
private Map<String, CustomClaim> sessionClaims = new ConcurrentHashMap<>();
private Map<String, String> sessionPrincipalNames = new ConcurrentHashMap<>();
MyWebSocketHandler(WebSocketHandler delegate, IWebSocketManageService webSocketManageService) { MyWebSocketHandler(WebSocketHandler delegate, IWebSocketManageService webSocketManageService) {
super(delegate); super(delegate);
this.webSocketManageService = webSocketManageService; this.webSocketManageService = webSocketManageService;
...@@ -30,29 +42,114 @@ public class MyWebSocketHandler extends WebSocketDefaultHandler { ...@@ -30,29 +42,114 @@ public class MyWebSocketHandler extends WebSocketDefaultHandler {
@Override @Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception { public void afterConnectionEstablished(WebSocketSession session) throws Exception {
Principal principal = session.getPrincipal(); Principal principal = session.getPrincipal();
if (StringUtils.hasText(principal.getName())) { String principalName = principal.getName();
webSocketManageService.put(principal.getName(), new MyConcurrentWebSocketSession(session));
log.debug("{} is connected. ID: {}. WebSocketSession[current count: {}]", if (StringUtils.hasText(principalName) && !principalName.startsWith("temp-")) {
principal.getName(), session.getId(), webSocketManageService.getConnectedCount()); webSocketManageService.put(principalName, new MyConcurrentWebSocketSession(session));
return; authenticatedSessions.put(session.getId(), true);
// 记录 principalName 用于 afterConnectionClosed 时清理
sessionPrincipalNames.put(session.getId(), principalName);
log.debug("{} is connected (pre-authenticated). ID: {}. WebSocketSession[current count: {}]",
principalName, session.getId(), webSocketManageService.getConnectedCount());
} else {
log.debug("Unauthenticated connection established. ID: {}, temp principal: {}",
session.getId(), principalName);
} }
session.close();
} }
@Override @Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception { public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
Principal principal = session.getPrincipal(); String sessionId = session.getId();
if (StringUtils.hasText(principal.getName())) { Boolean isAuthenticated = authenticatedSessions.get(sessionId);
webSocketManageService.remove(principal.getName(), session.getId());
if (Boolean.TRUE.equals(isAuthenticated)) {
// 优先用 sessionClaims(消息认证方式),其次用 sessionPrincipalNames(URL token 认证方式)
CustomClaim claim = sessionClaims.get(sessionId);
if (claim != null) {
String key = claim.getWorkspaceId() + "/" + claim.getUserType() + "/" + claim.getId();
webSocketManageService.remove(key, sessionId);
log.debug("{} is disconnected. ID: {}. WebSocketSession[current count: {}]", log.debug("{} is disconnected. ID: {}. WebSocketSession[current count: {}]",
principal.getName(), session.getId(), webSocketManageService.getConnectedCount()); key, sessionId, webSocketManageService.getConnectedCount());
} else {
String principalName = sessionPrincipalNames.get(sessionId);
if (principalName != null) {
webSocketManageService.remove(principalName, sessionId);
log.debug("{} is disconnected (pre-auth). ID: {}. WebSocketSession[current count: {}]",
principalName, sessionId, webSocketManageService.getConnectedCount());
}
}
} }
authenticatedSessions.remove(sessionId);
sessionClaims.remove(sessionId);
sessionPrincipalNames.remove(sessionId);
} }
@Override @Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception { public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
log.debug("received message: {}", message.getPayload()); String sessionId = session.getId();
Boolean isAuthenticated = authenticatedSessions.get(sessionId);
String payload = message.getPayload().toString();
log.debug("Received message from session {}: {}", sessionId, payload);
try {
JsonNode jsonNode = objectMapper.readTree(payload);
String type = jsonNode.has("type") ? jsonNode.get("type").asText() : null;
if ("auth".equals(type) && !Boolean.TRUE.equals(isAuthenticated)) {
String token = jsonNode.has("token") ? jsonNode.get("token").asText() : null;
if (StringUtils.hasText(token)) {
Optional<CustomClaim> customClaimOpt = JwtUtil.parseToken(token);
if (customClaimOpt.isPresent()) {
CustomClaim claim = customClaimOpt.get();
String key = claim.getWorkspaceId() + "/" + claim.getUserType() + "/" + claim.getId();
authenticatedSessions.put(sessionId, true);
sessionClaims.put(sessionId, claim);
webSocketManageService.put(key, new MyConcurrentWebSocketSession(session));
log.debug("Session {} authenticated successfully. User: {}", sessionId, key);
String authResponse = objectMapper.writeValueAsString(Map.of(
"type", "auth_success",
"status", "ok"
));
session.sendMessage(new org.springframework.web.socket.TextMessage(authResponse));
return;
}
}
log.warn("Authentication failed for session {}", sessionId);
String authFail = objectMapper.writeValueAsString(Map.of(
"type", "auth_fail",
"status", "error",
"message", "Invalid token"
));
session.sendMessage(new org.springframework.web.socket.TextMessage(authFail));
session.close(CloseStatus.NOT_ACCEPTABLE);
return;
}
if (!Boolean.TRUE.equals(isAuthenticated)) {
log.warn("Unauthenticated session {} tried to send message. Closing.", sessionId);
String authRequired = objectMapper.writeValueAsString(Map.of(
"type", "auth_required",
"message", "Please authenticate first"
));
session.sendMessage(new org.springframework.web.socket.TextMessage(authRequired));
// session.close(CloseStatus.NOT_ACCEPTABLE);
return;
}
super.handleMessage(session, message);
} catch (Exception e) {
log.error("Error handling message from session {}", sessionId, e);
if (!Boolean.TRUE.equals(isAuthenticated)) {
session.close(CloseStatus.BAD_DATA);
}
}
} }
} }
\ No newline at end of file
...@@ -63,9 +63,12 @@ public class WebSocketMessageServiceImpl implements IWebSocketMessageService { ...@@ -63,9 +63,12 @@ public class WebSocketMessageServiceImpl implements IWebSocketMessageService {
for (MyConcurrentWebSocketSession session : sessions) { for (MyConcurrentWebSocketSession session : sessions) {
if (!session.isOpen()) { if (!session.isOpen()) {
try {
session.close(); session.close();
log.debug("This session is closed."); } catch (Exception ignored) {
return; }
log.debug("Skipping closed session: {}", session.getId());
continue;
} }
session.sendMessage(data); session.sendMessage(data);
} }
......
...@@ -24,6 +24,12 @@ public class GlobalMVCConfigurer implements WebMvcConfigurer { ...@@ -24,6 +24,12 @@ public class GlobalMVCConfigurer implements WebMvcConfigurer {
@Value("${url.manage.version}") @Value("${url.manage.version}")
private String manageVersion; private String manageVersion;
@Value("${url.media.prefix}")
private String mediaPrefix;
@Value("${url.media.version}")
private String mediaVersion;
@Override @Override
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
...@@ -36,10 +42,21 @@ public class GlobalMVCConfigurer implements WebMvcConfigurer { ...@@ -36,10 +42,21 @@ public class GlobalMVCConfigurer implements WebMvcConfigurer {
excludePaths.add("/swagger-ui/**"); excludePaths.add("/swagger-ui/**");
excludePaths.add("/v3/**"); excludePaths.add("/v3/**");
excludePaths.add("/ui/**"); excludePaths.add("/ui/**");
excludePaths.add("/actuator/health");
excludePaths.add("/" + managePrefix + manageVersion + "/devices/**/deviceInfo"); excludePaths.add("/" + managePrefix + manageVersion + "/devices/**/deviceInfo");
excludePaths.add("/" + managePrefix + manageVersion + "/live/streams/start2"); excludePaths.add("/" + managePrefix + manageVersion + "/live/streams/start2");
excludePaths.add("/" + managePrefix + manageVersion + "/live/streams/stop2"); excludePaths.add("/" + managePrefix + manageVersion + "/live/streams/stop2");
excludePaths.add("/" + managePrefix + manageVersion + "/live/oneCapacity2"); excludePaths.add("/" + managePrefix + manageVersion + "/live/oneCapacity2");
// 放行 aiInfo 新增与修改接口
excludePaths.add("/" + managePrefix + manageVersion + "/aiInfo/add");
excludePaths.add("/" + managePrefix + manageVersion + "/aiInfo/update");
excludePaths.add("/" + managePrefix + manageVersion + "/aiInfo/addFromTopic");
// 放行 OSS 文件浏览接口
excludePaths.add("/" + mediaPrefix + mediaVersion + "/oss/**/files");
// 放行 OSS 文件下载接口
excludePaths.add("/" + mediaPrefix + mediaVersion + "/oss/**/file/**/download");
// Intercept for all request interfaces. // Intercept for all request interfaces.
registry.addInterceptor(authInterceptor).addPathPatterns("/**").excludePathPatterns(excludePaths); registry.addInterceptor(authInterceptor).addPathPatterns("/**").excludePathPatterns(excludePaths);
} }
......
package com.dji.sample.manage.controller; package com.dji.sample.manage.controller;
import com.dji.sample.manage.model.dto.*; import com.dji.sample.manage.model.dto.*;
import com.dji.sample.manage.model.enums.OperateRecordTypeEnum;
import com.dji.sample.manage.model.param.DeviceSearchParam; import com.dji.sample.manage.model.param.DeviceSearchParam;
import com.dji.sample.manage.service.IDeviceDictionaryService; import com.dji.sample.manage.service.IDeviceDictionaryService;
import com.dji.sample.manage.service.IOperateRecordService;
import com.dji.sample.manage.service.IDeviceService; import com.dji.sample.manage.service.IDeviceService;
import com.dji.sdk.common.HttpResultResponse; import com.dji.sdk.common.HttpResultResponse;
import com.dji.sdk.common.PaginationData; import com.dji.sdk.common.PaginationData;
...@@ -14,6 +16,7 @@ import org.springframework.beans.factory.annotation.Autowired; ...@@ -14,6 +16,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
...@@ -33,6 +36,9 @@ public class DeviceController { ...@@ -33,6 +36,9 @@ public class DeviceController {
@Autowired @Autowired
private IDeviceDictionaryService deviceDictionaryService; private IDeviceDictionaryService deviceDictionaryService;
@Autowired
private IOperateRecordService operateRecordService;
/** /**
* Get the topology list of all online devices in one workspace. * Get the topology list of all online devices in one workspace.
* @param workspaceId * @param workspaceId
...@@ -136,9 +142,19 @@ public class DeviceController { ...@@ -136,9 +142,19 @@ public class DeviceController {
* @return * @return
*/ */
@PostMapping("/{workspace_id}/devices/ota") @PostMapping("/{workspace_id}/devices/ota")
public HttpResultResponse createOtaJob(@PathVariable("workspace_id") String workspaceId, public HttpResultResponse createOtaJob(HttpServletRequest request,
@PathVariable("workspace_id") String workspaceId,
@RequestBody List<DeviceFirmwareUpgradeDTO> upgradeDTOS) { @RequestBody List<DeviceFirmwareUpgradeDTO> upgradeDTOS) {
return deviceService.createDeviceOtaJob(workspaceId, upgradeDTOS); HttpResultResponse response = deviceService.createDeviceOtaJob(workspaceId, upgradeDTOS);
// 下发成功后记录操作日志(operate_record)
if (response != null && response.getCode() == 0 && upgradeDTOS != null) {
for (DeviceFirmwareUpgradeDTO dto : upgradeDTOS) {
operateRecordService.record(request, OperateRecordTypeEnum.FIRMWARE_UPGRADE, dto.getSn(), dto);
}
}
return response;
} }
/** /**
...@@ -346,4 +362,23 @@ public class DeviceController { ...@@ -346,4 +362,23 @@ public class DeviceController {
return HttpResultResponse.success(deviceInfoTotal); return HttpResultResponse.success(deviceInfoTotal);
} }
/**
* Get the real-time OSD data of a device from Redis cache.
* Returns different OSD structures depending on device type:
* - Dock (domain=3): OsdDock (contains modeCode: Idle/Debugging/Working etc.)
* - Drone (domain=0): OsdDockDrone or OsdRcDrone (contains modeCode, lat/lng, altitude, speed, battery etc.)
* - Remote Controller (domain=2): OsdRemoteControl (contains lat/lng, height etc.)
* @param workspaceId
* @param deviceSn
* @return OSD data object
*/
@GetMapping("/{workspace_id}/devices/{device_sn}/osd")
public HttpResultResponse getDeviceOsd(@PathVariable("workspace_id") String workspaceId,
@PathVariable("device_sn") String deviceSn) {
Optional<Object> osdOpt = deviceService.getDeviceOsd(workspaceId, deviceSn);
return osdOpt.isEmpty()
? HttpResultResponse.error("Device OSD data not found. The device may be offline, not reporting data, or not belong to this workspace.")
: HttpResultResponse.success(osdOpt.get());
}
} }
\ No newline at end of file
...@@ -4,10 +4,12 @@ import com.dji.sample.common.model.CustomClaim; ...@@ -4,10 +4,12 @@ import com.dji.sample.common.model.CustomClaim;
import com.dji.sample.manage.model.dto.DeviceFirmwareDTO; import com.dji.sample.manage.model.dto.DeviceFirmwareDTO;
import com.dji.sample.manage.model.dto.DeviceFirmwareNoteDTO; import com.dji.sample.manage.model.dto.DeviceFirmwareNoteDTO;
import com.dji.sample.manage.model.dto.FirmwareFileProperties; import com.dji.sample.manage.model.dto.FirmwareFileProperties;
import com.dji.sample.manage.model.enums.OperateRecordTypeEnum;
import com.dji.sample.manage.model.param.DeviceFirmwareQueryParam; import com.dji.sample.manage.model.param.DeviceFirmwareQueryParam;
import com.dji.sample.manage.model.param.DeviceFirmwareUpdateParam; import com.dji.sample.manage.model.param.DeviceFirmwareUpdateParam;
import com.dji.sample.manage.model.param.DeviceFirmwareUploadParam; import com.dji.sample.manage.model.param.DeviceFirmwareUploadParam;
import com.dji.sample.manage.service.IDeviceFirmwareService; import com.dji.sample.manage.service.IDeviceFirmwareService;
import com.dji.sample.manage.service.IOperateRecordService;
import com.dji.sdk.common.HttpResultResponse; import com.dji.sdk.common.HttpResultResponse;
import com.dji.sdk.common.PaginationData; import com.dji.sdk.common.PaginationData;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
...@@ -37,6 +39,9 @@ public class DeviceFirmwareController { ...@@ -37,6 +39,9 @@ public class DeviceFirmwareController {
@Autowired @Autowired
private IDeviceFirmwareService service; private IDeviceFirmwareService service;
@Autowired
private IOperateRecordService operateRecordService;
/** /**
* Get the latest firmware version information for this device model. * Get the latest firmware version information for this device model.
* @param deviceNames * @param deviceNames
...@@ -89,10 +94,29 @@ public class DeviceFirmwareController { ...@@ -89,10 +94,29 @@ public class DeviceFirmwareController {
String creator = customClaim.getUsername(); String creator = customClaim.getUsername();
service.importFirmwareFile(workspaceId, creator, param, file); service.importFirmwareFile(workspaceId, creator, param, file);
// 记录操作日志(operate_record)
operateRecordService.record(request, OperateRecordTypeEnum.FIRMWARE_UPLOAD, null, param);
return HttpResultResponse.success(); return HttpResultResponse.success();
} }
/** /**
* Get firmware detail by firmwareId.
* @param workspaceId
* @param firmwareId
* @return
*/
@GetMapping("/{workspace_id}/firmwares/{firmware_id}")
public HttpResultResponse<DeviceFirmwareDTO> getFirmwareDetail(
@PathVariable("workspace_id") String workspaceId,
@PathVariable("firmware_id") String firmwareId) {
return service.getFirmwareDetail(workspaceId, firmwareId)
.map(HttpResultResponse::success)
.orElse(HttpResultResponse.error("Firmware not found."));
}
/**
* Change the firmware availability status. * Change the firmware availability status.
* @param workspaceId * @param workspaceId
* @param firmwareId * @param firmwareId
......
...@@ -152,7 +152,7 @@ public class UserController { ...@@ -152,7 +152,7 @@ public class UserController {
} }
/** /**
* Admin resets a user's password. * User reset password.
* The new password must comply with all password rules. * The new password must comply with all password rules.
* *
* @param request HTTP request * @param request HTTP request
...@@ -162,6 +162,7 @@ public class UserController { ...@@ -162,6 +162,7 @@ public class UserController {
@PostMapping("/resetPassword") @PostMapping("/resetPassword")
public HttpResultResponse<Object> resetPassword(HttpServletRequest request, public HttpResultResponse<Object> resetPassword(HttpServletRequest request,
@RequestBody ChangePasswordParam param) { @RequestBody ChangePasswordParam param) {
CustomClaim customClaim = (CustomClaim) request.getAttribute(TOKEN_CLAIM); CustomClaim customClaim = (CustomClaim) request.getAttribute(TOKEN_CLAIM);
String userId = customClaim.getId(); String userId = customClaim.getId();
return userService.resetPassword(userId, param); return userService.resetPassword(userId, param);
......
package com.dji.sample.manage.model.dto; package com.dji.sample.manage.model.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
/** /**
...@@ -42,4 +44,10 @@ public class DeviceFirmwareDTO { ...@@ -42,4 +44,10 @@ public class DeviceFirmwareDTO {
private String workspaceId; private String workspaceId;
private String username; private String username;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
} }
...@@ -29,7 +29,13 @@ public enum OperateRecordTypeEnum { ...@@ -29,7 +29,13 @@ public enum OperateRecordTypeEnum {
SEIZE_FLIGHT_AUTHORITY("SEIZE_FLIGHT_AUTHORITY", "夺取飞行控制权"), SEIZE_FLIGHT_AUTHORITY("SEIZE_FLIGHT_AUTHORITY", "夺取飞行控制权"),
SEIZE_PAYLOAD_AUTHORITY("SEIZE_PAYLOAD_AUTHORITY", "夺取负载控制权"), SEIZE_PAYLOAD_AUTHORITY("SEIZE_PAYLOAD_AUTHORITY", "夺取负载控制权"),
PAYLOAD_COMMANDS("PAYLOAD_COMMANDS", "负载控制指令"), PAYLOAD_COMMANDS("PAYLOAD_COMMANDS", "负载控制指令"),
RTK_CALIBRATION("RTK_CALIBRATION", "RTK标定"); RTK_CALIBRATION("RTK_CALIBRATION", "RTK标定"),
// -- FIRMWARE
FIRMWARE_UPLOAD("FIRMWARE_UPLOAD", "上传固件"),
FIRMWARE_UPGRADE("FIRMWARE_UPGRADE", "下发固件升级"),
;
private final String type; private final String type;
private final String description; private final String description;
......
...@@ -73,6 +73,15 @@ public interface IDeviceFirmwareService { ...@@ -73,6 +73,15 @@ public interface IDeviceFirmwareService {
void importFirmwareFile(String workspaceId, String creator, DeviceFirmwareUploadParam param, MultipartFile file); void importFirmwareFile(String workspaceId, String creator, DeviceFirmwareUploadParam param, MultipartFile file);
/** /**
* Get firmware detail by firmwareId.
*
* @param workspaceId
* @param firmwareId
* @return
*/
Optional<DeviceFirmwareDTO> getFirmwareDetail(String workspaceId, String firmwareId);
/**
* Save the file information of the firmware. * Save the file information of the firmware.
* @param firmware * @param firmware
* @param deviceNames * @param deviceNames
......
...@@ -294,4 +294,14 @@ public interface IDeviceService extends IService<DeviceEntity> { ...@@ -294,4 +294,14 @@ public interface IDeviceService extends IService<DeviceEntity> {
DeviceDetailCountDTO getDeviceInfoTotal(String workspaceId); DeviceDetailCountDTO getDeviceInfoTotal(String workspaceId);
/**
* Get the OSD real-time data of the device from Redis.
* Automatically determines the device type (Dock, Drone, RC) and returns the corresponding OSD data.
* Validates that the device belongs to the specified workspace.
* @param workspaceId
* @param deviceSn
* @return OSD data object, or empty if device not found or no OSD data
*/
Optional<Object> getDeviceOsd(String workspaceId, String deviceSn);
} }
\ No newline at end of file
...@@ -46,6 +46,7 @@ import java.io.InputStream; ...@@ -46,6 +46,7 @@ import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
...@@ -167,6 +168,15 @@ public class DeviceFirmwareServiceImpl extends AbstractFirmwareService implement ...@@ -167,6 +168,15 @@ public class DeviceFirmwareServiceImpl extends AbstractFirmwareService implement
} }
@Override @Override
public Optional<DeviceFirmwareDTO> getFirmwareDetail(String workspaceId, String firmwareId) {
return Optional.ofNullable(entity2Dto(mapper.selectOne(
new LambdaQueryWrapper<DeviceFirmwareEntity>()
.eq(DeviceFirmwareEntity::getWorkspaceId, workspaceId)
.eq(DeviceFirmwareEntity::getFirmwareId, firmwareId),
null)));
}
@Override
public Boolean checkFileExist(String workspaceId, String fileMd5) { public Boolean checkFileExist(String workspaceId, String fileMd5) {
return RedisOpsUtils.checkExist(RedisConst.FILE_UPLOADING_PREFIX + workspaceId + fileMd5) || return RedisOpsUtils.checkExist(RedisConst.FILE_UPLOADING_PREFIX + workspaceId + fileMd5) ||
mapper.selectCount(new LambdaQueryWrapper<DeviceFirmwareEntity>() mapper.selectCount(new LambdaQueryWrapper<DeviceFirmwareEntity>()
...@@ -337,6 +347,8 @@ public class DeviceFirmwareServiceImpl extends AbstractFirmwareService implement ...@@ -337,6 +347,8 @@ public class DeviceFirmwareServiceImpl extends AbstractFirmwareService implement
.firmwareStatus(entity.getStatus()) .firmwareStatus(entity.getStatus())
.workspaceId(entity.getWorkspaceId()) .workspaceId(entity.getWorkspaceId())
.username(entity.getUsername()) .username(entity.getUsername())
.createTime(entity.getCreateTime() != null ? LocalDateTime.ofInstant(Instant.ofEpochMilli(entity.getCreateTime()), ZoneId.systemDefault()) : null)
.updateTime(entity.getUpdateTime() != null ? LocalDateTime.ofInstant(Instant.ofEpochMilli(entity.getUpdateTime()), ZoneId.systemDefault()) : null)
.build(); .build();
} }
......
...@@ -512,6 +512,14 @@ public class DeviceServiceImpl extends ServiceImpl<IDeviceMapper, DeviceEntity> ...@@ -512,6 +512,14 @@ public class DeviceServiceImpl extends ServiceImpl<IDeviceMapper, DeviceEntity>
public Boolean bindDevice(DeviceDTO device) { public Boolean bindDevice(DeviceDTO device) {
device.setBoundStatus(true); device.setBoundStatus(true);
device.setBoundTime(LocalDateTime.now()); device.setBoundTime(LocalDateTime.now());
// 绑定设备补充orgId
Optional<DeviceDTO> dbDeviceBySn = this.getDeviceBySn(device.getDeviceSn());
if (dbDeviceBySn.isPresent()) {
DeviceDTO dbDeviceDTO = dbDeviceBySn.get();
if (!StringUtils.hasText(dbDeviceDTO.getOrgId())) {
device.setOrgId(getOrgId());
}
}
boolean isUpd = this.updateDevice(device); boolean isUpd = this.updateDevice(device);
if (!isUpd) { if (!isUpd) {
...@@ -1039,6 +1047,39 @@ public class DeviceServiceImpl extends ServiceImpl<IDeviceMapper, DeviceEntity> ...@@ -1039,6 +1047,39 @@ public class DeviceServiceImpl extends ServiceImpl<IDeviceMapper, DeviceEntity>
} }
@Override @Override
public Optional<Object> getDeviceOsd(String workspaceId, String deviceSn) {
Optional<DeviceDTO> deviceOpt = getDeviceBySn(deviceSn);
if (deviceOpt.isEmpty()) {
return Optional.empty();
}
DeviceDTO device = deviceOpt.get();
// Validate that the device belongs to the specified workspace
if (!workspaceId.equals(device.getWorkspaceId())) {
log.warn("Device {} does not belong to workspace {}", deviceSn, workspaceId);
return Optional.empty();
}
DeviceDomainEnum domain = device.getDomain();
if (domain == null) {
return Optional.empty();
}
switch (domain) {
case DOCK:
return deviceRedisService.getDeviceOsd(deviceSn, OsdDock.class).map(osd -> osd);
case DRONE:
// Try DockDrone first, then RcDrone
Optional<OsdDockDrone> dockDrone = deviceRedisService.getDeviceOsd(deviceSn, OsdDockDrone.class);
if (dockDrone.isPresent()) {
return dockDrone.map(osd -> osd);
}
return deviceRedisService.getDeviceOsd(deviceSn, OsdRcDrone.class).map(osd -> osd);
case REMOTER_CONTROL:
return deviceRedisService.getDeviceOsd(deviceSn, OsdRemoteControl.class).map(osd -> osd);
default:
return Optional.empty();
}
}
@Override
public Boolean checkDockDrcMode(String dockSn) { public Boolean checkDockDrcMode(String dockSn) {
if (CUSTOM_DOCK_LIST.contains(dockSn) || dockSn.contains("12345")) { if (CUSTOM_DOCK_LIST.contains(dockSn) || dockSn.contains("12345")) {
......
...@@ -153,7 +153,6 @@ public class LiveStreamServiceImpl implements ILiveStreamService { ...@@ -153,7 +153,6 @@ public class LiveStreamServiceImpl implements ILiveStreamService {
Set<String> existingSessions = findSessionKeys(liveParam.getVideoId()); Set<String> existingSessions = findSessionKeys(liveParam.getVideoId());
boolean streamAlreadyRunning = !forceMqttPush boolean streamAlreadyRunning = !forceMqttPush
&& existingSessions != null && !existingSessions.isEmpty(); && existingSessions != null && !existingSessions.isEmpty();
String rtspOutputUrl = null; // RTSP 特殊:播放地址来自设备响应
if (streamAlreadyRunning) { if (streamAlreadyRunning) {
log.info("Stream already running with {} viewer(s), skip MQTT push and reuse. videoId={}", log.info("Stream already running with {} viewer(s), skip MQTT push and reuse. videoId={}",
...@@ -166,7 +165,7 @@ public class LiveStreamServiceImpl implements ILiveStreamService { ...@@ -166,7 +165,7 @@ public class LiveStreamServiceImpl implements ILiveStreamService {
// videoQuality 为空时默认 AUTO(refresh 场景前端可能不传) // videoQuality 为空时默认 AUTO(refresh 场景前端可能不传)
VideoQualityEnum quality = liveParam.getVideoQuality() != null VideoQualityEnum quality = liveParam.getVideoQuality() != null
? liveParam.getVideoQuality() : VideoQualityEnum.AUTO; ? liveParam.getVideoQuality() : VideoQualityEnum.AUTO;
TopicServicesResponse<ServicesReplyData<String>> response = abstractLivestreamService.liveStartPush( TopicServicesResponse<ServicesReplyData<LiveStartPushResponse>> response = abstractLivestreamService.liveStartPush(
SDKManager.getDeviceSDK(responseResult.getData().getDeviceSn()), SDKManager.getDeviceSDK(responseResult.getData().getDeviceSn()),
new LiveStartPushRequest() new LiveStartPushRequest()
.setUrl(url) .setUrl(url)
...@@ -184,11 +183,6 @@ public class LiveStreamServiceImpl implements ILiveStreamService { ...@@ -184,11 +183,6 @@ public class LiveStreamServiceImpl implements ILiveStreamService {
return HttpResultResponse.error(response.getData().getResult()); return HttpResultResponse.error(response.getData().getResult());
} }
} }
// 保存 RTSP 设备返回的 output(仅首次推流时设备会返回)
if (StringUtils.hasText(response.getData().getOutput())) {
rtspOutputUrl = response.getData().getOutput();
}
} }
// ========== 构造播放地址(无论首个还是复用,URL 构造逻辑一致) ========== // ========== 构造播放地址(无论首个还是复用,URL 构造逻辑一致) ==========
...@@ -212,12 +206,7 @@ public class LiveStreamServiceImpl implements ILiveStreamService { ...@@ -212,12 +206,7 @@ public class LiveStreamServiceImpl implements ILiveStreamService {
.toString()); .toString());
break; break;
case RTSP: case RTSP:
// RTSP 优先用设备返回的 output 地址,复用时回退到配置 URL
if (StringUtils.hasText(rtspOutputUrl)) {
live.setUrl(rtspOutputUrl);
} else {
live.setUrl(url.toString()); live.setUrl(url.toString());
}
break; break;
case WHIP: case WHIP:
live.setUrl(url.toString().replace("whip", "whep")); live.setUrl(url.toString().replace("whip", "whep"));
......
...@@ -264,6 +264,7 @@ public class SDKDeviceService extends AbstractDeviceService { ...@@ -264,6 +264,7 @@ public class SDKDeviceService extends AbstractDeviceService {
} }
OsdRemoteControl data = request.getData(); OsdRemoteControl data = request.getData();
deviceRedisService.setDeviceOsd(from, data);
deviceService.pushOsdDataToPilot(device.getWorkspaceId(), from, deviceService.pushOsdDataToPilot(device.getWorkspaceId(), from,
new DeviceOsdHost() new DeviceOsdHost()
.setLatitude(data.getLatitude()) .setLatitude(data.getLatitude())
...@@ -297,6 +298,7 @@ public class SDKDeviceService extends AbstractDeviceService { ...@@ -297,6 +298,7 @@ public class SDKDeviceService extends AbstractDeviceService {
} }
OsdRcDrone data = request.getData(); OsdRcDrone data = request.getData();
deviceRedisService.setDeviceOsd(from, data);
deviceService.pushOsdDataToPilot(device.getWorkspaceId(), from, deviceService.pushOsdDataToPilot(device.getWorkspaceId(), from,
new DeviceOsdHost() new DeviceOsdHost()
.setLatitude(data.getLatitude()) .setLatitude(data.getLatitude())
......
...@@ -84,10 +84,10 @@ mqtt: ...@@ -84,10 +84,10 @@ mqtt:
# host: emqx-broker # host: emqx-broker
# host: 192.168.32.90 # host: 192.168.32.90
# port: 44418 # port: 44418
host: 203.186.109.106 # host: geotwin.cc
port: 54941 # port: 54941
# host: emqx-broker host: emqx-broker
# port: 1883 port: 1883
username: JavaServer username: JavaServer
password: 123456 password: 123456
client-id: 123456 client-id: 123456
...@@ -216,7 +216,7 @@ livestream: ...@@ -216,7 +216,7 @@ livestream:
# RTMP Note: This IP is the address of the streaming server. If you want to see livestream on web page, you need to convert the RTMP stream to WebRTC stream. # RTMP Note: This IP is the address of the streaming server. If you want to see livestream on web page, you need to convert the RTMP stream to WebRTC stream.
rtmp: rtmp:
url: rtmp://203.186.109.106:44424/live/ # Example: 'rtmp://192.168.1.1/live/' url: rtmp://geotwin.cc:44424/live/ # Example: 'rtmp://192.168.1.1/live/'
# 深圳 # 深圳
# url: rtmp://183.11.236.162:54424/live/ # Example: 'rtmp://192.168.1.1/live/' # url: rtmp://183.11.236.162:54424/live/ # Example: 'rtmp://192.168.1.1/live/'
rtsp: rtsp:
...@@ -252,6 +252,6 @@ uom: ...@@ -252,6 +252,6 @@ uom:
programVersion: "version1.0" programVersion: "version1.0"
# 上报平台 API 地址 https://uom.receive.caacic.cn/addFlightRoute # 上报平台 API 地址 https://uom.receive.caacic.cn/addFlightRoute
# url: https://218.189.32.212:8080/addFlightRoute # url: https://218.189.32.212:8080/addFlightRoute
url: https://220.232.168.7:8080/addFlightRoute url: https://118.143.38.126:8080/addFlightRoute
appID: GEOSYS appID: GEOSYS
appKey: geosys_uas appKey: geosys_uas
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment