feat: use tencent cos for uploads and add aslan stats

This commit is contained in:
zhx 2026-06-25 20:28:03 +08:00
parent 52658d5d65
commit 24e1af1841
26 changed files with 1145 additions and 14 deletions

View File

@ -289,6 +289,7 @@
<tencentcloud-cls-logback-appender.version>1.0.4</tencentcloud-cls-logback-appender.version>
<tencentcloud.version>3.1.877</tencentcloud.version>
<cos_api.version>5.6.151</cos_api.version>
<opentelemetry-logback.version>1.9.2-alpha</opentelemetry-logback.version>
<protobuf-java.version>3.5.1</protobuf-java.version>
<easyexcel.version>3.1.3</easyexcel.version>

View File

@ -36,3 +36,13 @@ management:
enabled: true
health:
show-details: always
gateway:
ignorePaths:
- /console/account/login
- /console/actuator/**
- /console/datav/active/user-country-code
- /console/datav/online/user/count
- /console/datav/online/room/count
- /console/datav/aslan/region-country/statistics
- /console/user/base/info/im/sig

View File

@ -2,7 +2,11 @@ package com.red.circle.external.inner.endpoint.oss.api;
import com.red.circle.framework.dto.ResultResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;
/**
* oss 服务api.
@ -19,6 +23,13 @@ public interface OssServiceClientApi {
@GetMapping("/sts")
ResultResponse<Object> sts();
/**
* 上传文件.
*/
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ResultResponse<String> upload(@RequestPart("file") MultipartFile file,
@RequestParam("key") String key);
/**
* 移除文件.
*/

View File

@ -2,15 +2,21 @@ package com.red.circle.console.adapter.app.party3rd;//package com.sugartime.app.
import com.red.circle.external.inner.endpoint.oss.OssServiceClient;
import com.red.circle.framework.core.asserts.ResponseAssert;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 阿里云服务.
* 对象存储服务.
*/
@Slf4j
@RequiredArgsConstructor
@ -21,11 +27,25 @@ public class AliYunRestController {
private final OssServiceClient ossServiceClient;
/**
* 阿里云sts.
* 对象存储上传配置.
*/
@GetMapping("/oss/sts")
public Object sts() {
return ResponseAssert.requiredSuccess(ossServiceClient.sts());
}
/**
* 上传文件.
*/
@PostMapping(value = "/oss/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Map<String, Object> upload(@RequestPart("file") MultipartFile file,
@RequestParam("key") String key) {
String url = ResponseAssert.requiredSuccess(ossServiceClient.upload(file, key));
Map<String, Object> result = new LinkedHashMap<>();
result.put("name", key);
result.put("url", url);
result.put("res", Map.of("status", 200));
return result;
}
}

View File

@ -1,14 +1,18 @@
package com.red.circle.console.adapter.app.user;
import com.red.circle.common.business.core.enums.SysOriginPlatformEnum;
import com.red.circle.console.app.dto.clienobject.count.aslan.AslanRegionCountryStatisticsCO;
import com.red.circle.console.app.service.app.count.AslanRegionCountryStatisticsService;
import com.red.circle.console.app.service.app.user.DatavService;
import com.red.circle.framework.web.controller.BaseController;
import com.red.circle.other.inner.model.cmd.user.OnlineCountQryCmd;
import com.red.circle.other.inner.model.dto.user.ActiveUserCountryCodeDTO;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@ -25,6 +29,7 @@ import org.springframework.web.bind.annotation.RestController;
public class DatavRestController extends BaseController {
private final DatavService userExpandService;
private final AslanRegionCountryStatisticsService aslanRegionCountryStatisticsService;
@GetMapping("/active/user-country-code")
public List<ActiveUserCountryCodeDTO> latestActiveUserCountryCode() {
@ -41,4 +46,11 @@ public class DatavRestController extends BaseController {
return Objects.toString(userExpandService.count(sysOrigin));
}
@GetMapping("/aslan/region-country/statistics")
public List<AslanRegionCountryStatisticsCO> aslanRegionCountryStatistics(
@RequestParam(value = "date", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) {
return aslanRegionCountryStatisticsService.listRegionCountryStatistics(date);
}
}

View File

@ -0,0 +1,367 @@
package com.red.circle.console.app.service.app.count;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.group;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.match;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation;
import static org.springframework.data.mongodb.core.aggregation.Aggregation.project;
import com.mongodb.BasicDBObject;
import com.red.circle.common.business.core.enums.SysOriginPlatformEnum;
import com.red.circle.console.app.dto.clienobject.count.aslan.AslanCountryStatisticsCO;
import com.red.circle.console.app.dto.clienobject.count.aslan.AslanRegionCountryStatisticsCO;
import com.red.circle.console.app.service.app.sys.SysCountryCodeService;
import com.red.circle.console.infra.database.rds.dao.count.AslanRegionCountryStatisticsDAO;
import com.red.circle.console.infra.database.rds.dto.count.CountryAmountMetricDTO;
import com.red.circle.console.infra.database.rds.dto.count.CountryLongMetricDTO;
import com.red.circle.console.infra.database.rds.dto.count.UserCountryMetricDTO;
import com.red.circle.framework.core.asserts.ResponseAssert;
import com.red.circle.other.inner.endpoint.user.region.RegionConfigClient;
import com.red.circle.other.inner.model.cmd.user.SysRegionConfigQryCmd;
import com.red.circle.other.inner.model.dto.sys.SysCountryCodeDTO;
import com.red.circle.other.inner.model.dto.user.reigon.RegionConfigDTO;
import com.red.circle.tool.core.collection.CollectionUtils;
import com.red.circle.tool.core.text.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Service;
/**
* Aslan 区域国家统计.
*/
@Service
@RequiredArgsConstructor
public class AslanRegionCountryStatisticsServiceImpl implements AslanRegionCountryStatisticsService {
private static final String DAILY_ACTIVE_COLLECTION = "user_daily_active_log";
private static final String TEAM_MEMBER_COLLECTION = "team_member";
private static final String BANK_BALANCE_COLLECTION = "user_bank_balance";
private static final Pattern COUNTRY_CODE_PATTERN = Pattern.compile("[A-Za-z0-9_]+");
private static final ZoneId ASIA_SHANGHAI = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DATE_NUMBER_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE;
private static final int USER_ID_CHUNK_SIZE = 1000;
private final MongoTemplate mongoTemplate;
private final RegionConfigClient regionConfigClient;
private final SysCountryCodeService sysCountryCodeService;
private final AslanRegionCountryStatisticsDAO statisticsDAO;
@Override
public List<AslanRegionCountryStatisticsCO> listRegionCountryStatistics(LocalDate date) {
LocalDate statDate = Objects.requireNonNullElseGet(date,
() -> LocalDate.now(ASIA_SHANGHAI));
String sysOrigin = SysOriginPlatformEnum.ATYOU.name();
var startTime = statDate.atStartOfDay();
var endTime = statDate.plusDays(1).atStartOfDay();
Integer dateNumber = Integer.parseInt(statDate.format(DATE_NUMBER_FORMATTER));
List<RegionConfigDTO> regions = listRegionConfigs(sysOrigin);
if (CollectionUtils.isEmpty(regions)) {
return CollectionUtils.newArrayList();
}
Map<String, SysCountryCodeDTO> countryMap = mapCountryCode();
Map<String, BigDecimal> rechargeMap = mapAmount(
statisticsDAO.listDailyRechargeByCountry(sysOrigin, dateNumber, startTime, endTime));
Map<String, Long> registerMap = mapLong(
statisticsDAO.listDailyRegisterByCountry(sysOrigin, startTime, endTime));
Map<String, Long> goldBalanceMap = mapLong(statisticsDAO.listGoldBalanceByCountry(sysOrigin));
Map<String, Long> dailyActiveMap = mapDailyActive(sysOrigin, statDate);
Map<String, BigDecimal> hostSalaryBalanceMap = mapHostSalaryBalance(sysOrigin);
return regions.stream()
.sorted(Comparator.comparing(RegionConfigDTO::getCreateTime,
Comparator.nullsLast(Comparator.naturalOrder())).reversed())
.map(region -> buildRegionStatistics(statDate, region, countryMap, rechargeMap,
registerMap, goldBalanceMap, dailyActiveMap, hostSalaryBalanceMap))
.toList();
}
private List<RegionConfigDTO> listRegionConfigs(String sysOrigin) {
SysRegionConfigQryCmd query = new SysRegionConfigQryCmd();
query.setSysOrigin(sysOrigin);
return Optional.ofNullable(ResponseAssert.requiredSuccess(regionConfigClient.listByCondition(query)))
.orElseGet(CollectionUtils::newArrayList);
}
private Map<String, SysCountryCodeDTO> mapCountryCode() {
List<SysCountryCodeDTO> countries = sysCountryCodeService.listContent();
if (CollectionUtils.isEmpty(countries)) {
return CollectionUtils.newHashMap();
}
return countries.stream()
.filter(country -> StringUtils.isNotBlank(country.getAlphaTwo()))
.collect(Collectors.toMap(
country -> normalizeCountryCode(country.getAlphaTwo()),
Function.identity(),
(left, right) -> left));
}
private AslanRegionCountryStatisticsCO buildRegionStatistics(
LocalDate statDate,
RegionConfigDTO region,
Map<String, SysCountryCodeDTO> countryMap,
Map<String, BigDecimal> rechargeMap,
Map<String, Long> registerMap,
Map<String, Long> goldBalanceMap,
Map<String, Long> dailyActiveMap,
Map<String, BigDecimal> hostSalaryBalanceMap) {
List<AslanCountryStatisticsCO> countries = parseCountryCodes(region.getCountryCodes()).stream()
.map(countryCode -> buildCountryStatistics(countryCode, countryMap, rechargeMap,
registerMap, goldBalanceMap, dailyActiveMap, hostSalaryBalanceMap))
.toList();
BigDecimal regionRecharge = countries.stream()
.map(AslanCountryStatisticsCO::getDailyRecharge)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
Long regionActive = countries.stream().mapToLong(AslanCountryStatisticsCO::getDailyActive).sum();
Long regionRegister = countries.stream().mapToLong(AslanCountryStatisticsCO::getDailyRegister)
.sum();
Long regionGoldBalance = countries.stream().mapToLong(AslanCountryStatisticsCO::getGoldBalance)
.sum();
BigDecimal regionHostSalaryBalance = countries.stream()
.map(AslanCountryStatisticsCO::getHostSalaryBalance)
.filter(Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return new AslanRegionCountryStatisticsCO()
.setDate(statDate.toString())
.setRegionId(region.getId())
.setRegionCode(region.getRegionCode())
.setRegionName(region.getRegionName())
.setDailyRecharge(regionRecharge)
.setDailyActive(regionActive)
.setDailyRegister(regionRegister)
.setGoldBalance(regionGoldBalance)
.setHostSalaryBalance(regionHostSalaryBalance)
.setCountries(countries);
}
private AslanCountryStatisticsCO buildCountryStatistics(
String countryCode,
Map<String, SysCountryCodeDTO> countryMap,
Map<String, BigDecimal> rechargeMap,
Map<String, Long> registerMap,
Map<String, Long> goldBalanceMap,
Map<String, Long> dailyActiveMap,
Map<String, BigDecimal> hostSalaryBalanceMap) {
SysCountryCodeDTO country = countryMap.get(countryCode);
return new AslanCountryStatisticsCO()
.setCountryCode(countryCode)
.setCountryName(Optional.ofNullable(country)
.map(SysCountryCodeDTO::getCountryName)
.filter(StringUtils::isNotBlank)
.orElse(countryCode))
.setDailyRecharge(rechargeMap.getOrDefault(countryCode, BigDecimal.ZERO))
.setDailyActive(dailyActiveMap.getOrDefault(countryCode, 0L))
.setDailyRegister(registerMap.getOrDefault(countryCode, 0L))
.setGoldBalance(goldBalanceMap.getOrDefault(countryCode, 0L))
.setHostSalaryBalance(hostSalaryBalanceMap.getOrDefault(countryCode, BigDecimal.ZERO));
}
private Map<String, BigDecimal> mapAmount(List<CountryAmountMetricDTO> metrics) {
if (CollectionUtils.isEmpty(metrics)) {
return CollectionUtils.newHashMap();
}
return metrics.stream()
.filter(metric -> StringUtils.isNotBlank(metric.getCountryCode()))
.collect(Collectors.toMap(
metric -> normalizeCountryCode(metric.getCountryCode()),
metric -> Optional.ofNullable(metric.getAmount()).orElse(BigDecimal.ZERO),
BigDecimal::add));
}
private Map<String, Long> mapLong(List<CountryLongMetricDTO> metrics) {
if (CollectionUtils.isEmpty(metrics)) {
return CollectionUtils.newHashMap();
}
return metrics.stream()
.filter(metric -> StringUtils.isNotBlank(metric.getCountryCode()))
.collect(Collectors.toMap(
metric -> normalizeCountryCode(metric.getCountryCode()),
metric -> Optional.ofNullable(metric.getQuantity()).orElse(0L),
Long::sum));
}
private Map<String, Long> mapDailyActive(String sysOrigin, LocalDate statDate) {
var aggregation = newAggregation(
match(Criteria.where("activeDate").is(statDate.toString()).and("sysOrigin").is(sysOrigin)),
group("registerCountryCode").count().as("quantity"),
project("quantity").and("_id").as("countryCode")
);
var results = mongoTemplate.aggregate(aggregation, DAILY_ACTIVE_COLLECTION, BasicDBObject.class)
.getMappedResults();
if (CollectionUtils.isEmpty(results)) {
return CollectionUtils.newHashMap();
}
return results.stream()
.filter(result -> StringUtils.isNotBlank(result.getString("countryCode")))
.collect(Collectors.toMap(
result -> normalizeCountryCode(result.getString("countryCode")),
result -> Optional.ofNullable(result.get("quantity"))
.map(Number.class::cast)
.map(Number::longValue)
.orElse(0L),
Long::sum));
}
private Map<String, BigDecimal> mapHostSalaryBalance(String sysOrigin) {
Set<Long> anchorUserIds = listAnchorUserIds(sysOrigin);
if (CollectionUtils.isEmpty(anchorUserIds)) {
return CollectionUtils.newHashMap();
}
Map<Long, Long> bankBalanceMap = mapBankBalance(sysOrigin, anchorUserIds);
if (CollectionUtils.isEmpty(bankBalanceMap)) {
return CollectionUtils.newHashMap();
}
Map<Long, String> userCountryMap = mapUserCountry(sysOrigin, bankBalanceMap.keySet());
if (CollectionUtils.isEmpty(userCountryMap)) {
return CollectionUtils.newHashMap();
}
Map<String, BigDecimal> result = CollectionUtils.newHashMap();
bankBalanceMap.forEach((userId, balance) -> {
String countryCode = userCountryMap.get(userId);
if (StringUtils.isBlank(countryCode)) {
return;
}
result.merge(countryCode, pennyToDollar(balance), BigDecimal::add);
});
return result;
}
private Set<Long> listAnchorUserIds(String sysOrigin) {
Query query = Query.query(Criteria.where("sysOrigin").is(sysOrigin)
.and("role").in("MEMBER", "ADMIN"));
query.fields().include("memberId");
List<BasicDBObject> members = mongoTemplate.find(query, BasicDBObject.class,
TEAM_MEMBER_COLLECTION);
if (CollectionUtils.isEmpty(members)) {
return Collections.emptySet();
}
return members.stream()
.map(member -> numberToLong(member.get("memberId")))
.filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
private Map<Long, Long> mapBankBalance(String sysOrigin, Set<Long> userIds) {
Map<Long, Long> result = CollectionUtils.newHashMap();
for (List<Long> chunk : partitionUserIds(userIds)) {
Query query = Query.query(Criteria.where("sysOrigin").is(sysOrigin).and("_id").in(chunk));
query.fields().include("_id").include("balance");
List<BasicDBObject> balances = mongoTemplate.find(query, BasicDBObject.class,
BANK_BALANCE_COLLECTION);
if (CollectionUtils.isEmpty(balances)) {
continue;
}
balances.forEach(balance -> {
Long userId = numberToLong(balance.get("_id"));
Long amount = numberToLong(balance.get("balance"));
if (Objects.nonNull(userId) && Objects.nonNull(amount)) {
result.merge(userId, amount, Long::sum);
}
});
}
return result;
}
private Map<Long, String> mapUserCountry(String sysOrigin, Set<Long> userIds) {
Map<Long, String> result = CollectionUtils.newHashMap();
for (List<Long> chunk : partitionUserIds(userIds)) {
List<UserCountryMetricDTO> users = statisticsDAO.listUserCountryByIds(sysOrigin, chunk);
if (CollectionUtils.isEmpty(users)) {
continue;
}
users.stream()
.filter(user -> Objects.nonNull(user.getUserId()))
.filter(user -> StringUtils.isNotBlank(user.getCountryCode()))
.forEach(user -> result.put(user.getUserId(), normalizeCountryCode(user.getCountryCode())));
}
return result;
}
private List<List<Long>> partitionUserIds(Set<Long> userIds) {
if (CollectionUtils.isEmpty(userIds)) {
return Collections.emptyList();
}
List<Long> ids = userIds.stream().filter(Objects::nonNull).distinct().toList();
List<List<Long>> partitions = new ArrayList<>();
for (int index = 0; index < ids.size(); index += USER_ID_CHUNK_SIZE) {
partitions.add(ids.subList(index, Math.min(index + USER_ID_CHUNK_SIZE, ids.size())));
}
return partitions;
}
private BigDecimal pennyToDollar(Long pennyAmount) {
if (Objects.isNull(pennyAmount)) {
return BigDecimal.ZERO;
}
return BigDecimal.valueOf(pennyAmount).divide(BigDecimal.valueOf(100), 2, RoundingMode.DOWN);
}
private Long numberToLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (Objects.isNull(value) || StringUtils.isBlank(value.toString())) {
return null;
}
return Long.valueOf(value.toString());
}
private Set<String> parseCountryCodes(String countryCodes) {
if (StringUtils.isBlank(countryCodes)) {
return Collections.emptySet();
}
Set<String> result = new LinkedHashSet<>();
Matcher matcher = COUNTRY_CODE_PATTERN.matcher(countryCodes);
while (matcher.find()) {
String code = normalizeCountryCode(matcher.group());
if (StringUtils.isNotBlank(code)) {
result.add(code);
}
}
return Collections.unmodifiableSet(result);
}
private String normalizeCountryCode(String countryCode) {
return StringUtils.isBlank(countryCode)
? ""
: countryCode.trim().toUpperCase(Locale.ROOT);
}
}

View File

@ -0,0 +1,55 @@
package com.red.circle.console.app.dto.clienobject.count.aslan;
import com.red.circle.framework.dto.DTO;
import java.io.Serial;
import java.math.BigDecimal;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/**
* Aslan 国家统计.
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
public class AslanCountryStatisticsCO extends DTO {
@Serial
private static final long serialVersionUID = 1L;
/**
* 国家编码.
*/
private String countryCode;
/**
* 国家名称.
*/
private String countryName;
/**
* 每日充值.
*/
private BigDecimal dailyRecharge;
/**
* 日活.
*/
private Long dailyActive;
/**
* 每日新注册.
*/
private Long dailyRegister;
/**
* 当前金币总量.
*/
private Long goldBalance;
/**
* 主播当前工资余额.
*/
private BigDecimal hostSalaryBalance;
}

View File

@ -0,0 +1,71 @@
package com.red.circle.console.app.dto.clienobject.count.aslan;
import com.red.circle.framework.dto.DTO;
import java.io.Serial;
import java.math.BigDecimal;
import java.util.List;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/**
* Aslan 区域国家统计.
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
public class AslanRegionCountryStatisticsCO extends DTO {
@Serial
private static final long serialVersionUID = 1L;
/**
* 统计日期.
*/
private String date;
/**
* 区域ID.
*/
private String regionId;
/**
* 区域编码.
*/
private String regionCode;
/**
* 区域名称.
*/
private String regionName;
/**
* 区域每日充值合计.
*/
private BigDecimal dailyRecharge;
/**
* 区域日活合计.
*/
private Long dailyActive;
/**
* 区域每日新注册合计.
*/
private Long dailyRegister;
/**
* 区域当前金币总量.
*/
private Long goldBalance;
/**
* 区域主播当前工资余额.
*/
private BigDecimal hostSalaryBalance;
/**
* 区域下国家统计.
*/
private List<AslanCountryStatisticsCO> countries;
}

View File

@ -0,0 +1,16 @@
package com.red.circle.console.app.service.app.count;
import com.red.circle.console.app.dto.clienobject.count.aslan.AslanRegionCountryStatisticsCO;
import java.time.LocalDate;
import java.util.List;
/**
* Aslan 区域国家统计.
*/
public interface AslanRegionCountryStatisticsService {
/**
* 获取指定日期的区域国家聚合数据.
*/
List<AslanRegionCountryStatisticsCO> listRegionCountryStatistics(LocalDate date);
}

View File

@ -0,0 +1,45 @@
package com.red.circle.console.infra.database.rds.dao.count;
import com.red.circle.console.infra.database.rds.dto.count.CountryAmountMetricDTO;
import com.red.circle.console.infra.database.rds.dto.count.CountryLongMetricDTO;
import com.red.circle.console.infra.database.rds.dto.count.UserCountryMetricDTO;
import java.time.LocalDateTime;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* Aslan 区域国家统计 Mapper.
*/
@Mapper
public interface AslanRegionCountryStatisticsDAO {
/**
* 每日充值包含订单后台离线充值和币商充值.
*/
List<CountryAmountMetricDTO> listDailyRechargeByCountry(
@Param("sysOrigin") String sysOrigin,
@Param("dateNumber") Integer dateNumber,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* 每日新注册.
*/
List<CountryLongMetricDTO> listDailyRegisterByCountry(
@Param("sysOrigin") String sysOrigin,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* 当前金币总量.
*/
List<CountryLongMetricDTO> listGoldBalanceByCountry(@Param("sysOrigin") String sysOrigin);
/**
* 批量查询用户国家.
*/
List<UserCountryMetricDTO> listUserCountryByIds(
@Param("sysOrigin") String sysOrigin,
@Param("userIds") List<Long> userIds);
}

View File

@ -0,0 +1,24 @@
package com.red.circle.console.infra.database.rds.dto.count;
import java.io.Serializable;
import java.math.BigDecimal;
import lombok.Data;
/**
* 国家金额指标.
*/
@Data
public class CountryAmountMetricDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 国家编码.
*/
private String countryCode;
/**
* 金额.
*/
private BigDecimal amount;
}

View File

@ -0,0 +1,23 @@
package com.red.circle.console.infra.database.rds.dto.count;
import java.io.Serializable;
import lombok.Data;
/**
* 国家数值指标.
*/
@Data
public class CountryLongMetricDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 国家编码.
*/
private String countryCode;
/**
* 数量.
*/
private Long quantity;
}

View File

@ -0,0 +1,23 @@
package com.red.circle.console.infra.database.rds.dto.count;
import java.io.Serializable;
import lombok.Data;
/**
* 用户国家指标.
*/
@Data
public class UserCountryMetricDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 用户ID.
*/
private Long userId;
/**
* 国家编码.
*/
private String countryCode;
}

View File

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper
namespace="com.red.circle.console.infra.database.rds.dao.count.AslanRegionCountryStatisticsDAO">
<select id="listDailyRechargeByCountry"
resultType="com.red.circle.console.infra.database.rds.dto.count.CountryAmountMetricDTO">
SELECT countryCode,
SUM(amount) AS amount
FROM (
SELECT UPPER(ubi.country_code) AS countryCode,
SUM(oph.unit_price) AS amount
FROM order_purchase_history oph
INNER JOIN user_base_info ubi ON ubi.id = oph.user_id
WHERE oph.sys_origin = #{sysOrigin}
AND oph.evn = 'PROD'
AND oph.status = 'COMPLETE'
AND IFNULL(oph.is_trial_period, 0) = 0
AND oph.update_time &gt;= #{startTime}
AND oph.update_time &lt; #{endTime}
GROUP BY UPPER(ubi.country_code)
UNION ALL
SELECT UPPER(ubi.country_code) AS countryCode,
SUM(oor.amount) AS amount
FROM order_other_recharge oor
INNER JOIN user_base_info ubi ON ubi.id = oor.user_Id
WHERE oor.sys_origin = #{sysOrigin}
AND oor.payment_date_number = #{dateNumber}
GROUP BY UPPER(ubi.country_code)
UNION ALL
SELECT UPPER(ubi.country_code) AS countryCode,
SUM(ugcr.recharge_amount) AS amount
FROM user_gold_coin_recharge ugcr
INNER JOIN user_base_info ubi ON ubi.id = ugcr.user_id
WHERE ugcr.sys_origin = #{sysOrigin}
AND ugcr.gold_coin_source = 2
AND ugcr.create_time &gt;= #{startTime}
AND ugcr.create_time &lt; #{endTime}
GROUP BY UPPER(ubi.country_code)
) recharge
WHERE countryCode IS NOT NULL
AND countryCode != ''
GROUP BY countryCode
</select>
<select id="listDailyRegisterByCountry"
resultType="com.red.circle.console.infra.database.rds.dto.count.CountryLongMetricDTO">
SELECT UPPER(country_code) AS countryCode,
COUNT(1) AS quantity
FROM user_base_info
WHERE origin_sys = #{sysOrigin}
AND create_time &gt;= #{startTime}
AND create_time &lt; #{endTime}
AND country_code IS NOT NULL
AND country_code != ''
GROUP BY UPPER(country_code)
</select>
<select id="listGoldBalanceByCountry"
resultType="com.red.circle.console.infra.database.rds.dto.count.CountryLongMetricDTO">
SELECT UPPER(ubi.country_code) AS countryCode,
SUM(GREATEST(wgb.income - wgb.expenditure, 0)) AS quantity
FROM wallet_gold_balance wgb
INNER JOIN user_base_info ubi ON ubi.id = wgb.id
WHERE wgb.sys_origin = #{sysOrigin}
AND ubi.origin_sys = #{sysOrigin}
AND ubi.country_code IS NOT NULL
AND ubi.country_code != ''
GROUP BY UPPER(ubi.country_code)
</select>
<select id="listUserCountryByIds"
resultType="com.red.circle.console.infra.database.rds.dto.count.UserCountryMetricDTO">
SELECT id AS userId,
UPPER(country_code) AS countryCode
FROM user_base_info
WHERE origin_sys = #{sysOrigin}
AND country_code IS NOT NULL
AND country_code != ''
AND id IN
<foreach collection="userIds" item="userId" open="(" separator="," close=")">
#{userId}
</foreach>
</select>
</mapper>

View File

@ -69,4 +69,5 @@ red-circle:
- /console/datav/active/user-country-code
- /console/datav/online/user/count
- /console/datav/online/room/count
- /console/datav/aslan/region-country/statistics
- /console/user/base/info/im/sig

View File

@ -1,8 +1,7 @@
package com.red.circle.external.adapter.app.oss;
import com.red.circle.component.oss.OssService;
import com.red.circle.component.oss.aliyun.service.AliYunStsResponse;
import com.red.circle.component.oss.aliyun.service.AliYunStsService;
import com.red.circle.external.infra.oss.TencentCosCredentialService;
import com.red.circle.external.response.OssErrorCode;
import com.red.circle.external.utils.FileUtils;
import com.red.circle.framework.dto.ResultResponse;
@ -13,11 +12,10 @@ import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.UUID;
/**
* 阿里云,OSS存储.
* 对象存储.
*
* @author pengliang on 2020/9/7
* @eo.api-type http
@ -30,11 +28,11 @@ import java.util.UUID;
@RequestMapping(value = "/oss", produces = MediaType.APPLICATION_JSON_VALUE)
public class OssRestController {
private final AliYunStsService aliYunStsService;
private final TencentCosCredentialService tencentCosCredentialService;
private final OssService ossService;
/**
* sts临时令牌.
* COS 上传配置.
*
* @eo.name sts临时令牌.
* @eo.url /sts
@ -43,8 +41,8 @@ public class OssRestController {
*/
@IgnoreResultResponse
@GetMapping("/sts")
public AliYunStsResponse sts() {
return aliYunStsService.getAcsResponse();
public Object sts() {
return tencentCosCredentialService.getResponse();
}
/**

View File

@ -36,6 +36,12 @@
<artifactId>component-oss</artifactId>
</dependency>
<dependency>
<groupId>com.qcloud</groupId>
<artifactId>cos_api</artifactId>
<version>${cos_api.version}</version>
</dependency>
<dependency>
<groupId>com.red.circle</groupId>
<artifactId>component-censor</artifactId>

View File

@ -0,0 +1,26 @@
package com.red.circle.external.infra.oss;
import com.red.circle.external.infra.props.TencentCosProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* COS 配置信息服务.
*/
@Service
@RequiredArgsConstructor
public class TencentCosCredentialService {
private final TencentCosProperties properties;
public TencentCosStsResponse getResponse() {
return TencentCosStsResponse.builder()
.provider("tencent-cos")
.bucketName(properties.getBucketName())
.region(properties.getRegion())
.accessUrl(properties.normalizedAccessUrl())
.uploadMode("server")
.build();
}
}

View File

@ -0,0 +1,226 @@
package com.red.circle.external.infra.oss;
import com.qcloud.cos.COSClient;
import com.qcloud.cos.ClientConfig;
import com.qcloud.cos.auth.BasicCOSCredentials;
import com.qcloud.cos.auth.COSCredentials;
import com.qcloud.cos.exception.CosClientException;
import com.qcloud.cos.model.COSObject;
import com.qcloud.cos.model.CopyObjectRequest;
import com.qcloud.cos.model.ObjectMetadata;
import com.qcloud.cos.model.PutObjectRequest;
import com.qcloud.cos.model.PutObjectResult;
import com.qcloud.cos.model.ciModel.common.ImageProcessRequest;
import com.qcloud.cos.model.ciModel.persistence.PicOperations;
import com.qcloud.cos.region.Region;
import com.red.circle.component.oss.OssService;
import com.red.circle.external.infra.props.TencentCosProperties;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
/**
* 腾讯云 COS 对象存储实现.
*/
@Slf4j
@Service("ossService")
@RequiredArgsConstructor
public class TencentCosOssService implements OssService {
private static final String VIDEO_COVER_PROCESS = "?ci-process=snapshot&time=1&format=jpg";
private static final String OSS_RESIZE_PREFIX = "image/resize,m_lfit,h_";
private static final String COS_IMAGE_RULE_PREFIX = "imageMogr2/";
private final TencentCosProperties properties;
private COSClient cosClient;
@PostConstruct
public void init() {
properties.validate();
COSCredentials credentials = new BasicCOSCredentials(properties.getSecretId(), properties.getSecretKey());
ClientConfig clientConfig = new ClientConfig(new Region(properties.getRegion()));
clientConfig.setConnectionTimeout(10_000);
clientConfig.setSocketTimeout(30_000);
this.cosClient = new COSClient(credentials, clientConfig);
}
@PreDestroy
public void destroy() {
if (cosClient != null) {
cosClient.shutdown();
}
}
@Override
public void uploadContent(String content, String fileName) {
byte[] bytes = StringUtils.defaultString(content).getBytes(StandardCharsets.UTF_8);
uploadByte(bytes, fileName);
}
@Override
public void uploadByte(byte[] bytes, String fileName) {
byte[] safeBytes = bytes == null ? new byte[0] : bytes;
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(safeBytes.length);
uploadInputStream(new ByteArrayInputStream(safeBytes), fileName, metadata);
}
@Override
public void uploadUrl(String url, String fileName) throws IOException {
try (InputStream inputStream = new URL(url).openStream()) {
uploadInputStream(inputStream, fileName);
}
}
@Override
public void uploadInputStream(InputStream inputStream, String fileName) {
uploadInputStream(inputStream, fileName, new ObjectMetadata());
}
@Override
public void uploadFile(File file, String fileName) {
String key = normalizeKey(fileName);
PutObjectRequest request = new PutObjectRequest(properties.getBucketName(), key, file);
cosClient.putObject(request);
}
@Override
public void remove(String url) {
String key = getFilePath(url);
if (StringUtils.isBlank(key)) {
return;
}
cosClient.deleteObject(properties.getBucketName(), key);
}
@Override
public String getAccessUrl(String key) {
if (StringUtils.isBlank(key)) {
return "";
}
if (isHttpUrl(key)) {
return key;
}
return properties.normalizedAccessUrl() + "/" + normalizeKey(key);
}
@Override
public String getVideoCover(String key) {
String accessUrl = getAccessUrl(key);
if (StringUtils.isBlank(accessUrl)) {
return "";
}
return accessUrl + VIDEO_COVER_PROCESS;
}
@Override
public void processFileSaveAs(String source, String target, String styleType) {
String sourceKey = getFilePath(source);
String targetKey = normalizeKey(target);
if (StringUtils.isAnyBlank(sourceKey, targetKey)) {
return;
}
try {
PicOperations picOperations = new PicOperations();
picOperations.setIsPicInfo(1);
PicOperations.Rule rule = new PicOperations.Rule();
rule.setBucket(properties.getBucketName());
rule.setFileId(targetKey);
rule.setRule(toCosImageRule(styleType));
picOperations.setRules(List.of(rule));
ImageProcessRequest request = new ImageProcessRequest(properties.getBucketName(), sourceKey);
request.setPicOperations(picOperations);
cosClient.processImage(request);
} catch (CosClientException ex) {
log.warn("COS image process failed, fallback to object copy. source={}, target={}, message={}",
sourceKey, targetKey, ex.getMessage());
copySourceToTarget(source, sourceKey, targetKey);
}
}
@Override
public String processImgSaveAsCompressZoom(String source, String target, int height) {
if (isHttpUrl(source)) {
return source;
}
if (StringUtils.endsWith(source, ".gift")) {
return getAccessUrl(source);
}
processFileSaveAs(source, target, OSS_RESIZE_PREFIX + height);
return getAccessUrl(target);
}
@Override
public String processImgSaveAsCompressZoom(String source, int height) {
return processImgSaveAsCompressZoom(source, source, height);
}
private void uploadInputStream(InputStream inputStream, String fileName, ObjectMetadata metadata) {
String key = normalizeKey(fileName);
PutObjectRequest request = new PutObjectRequest(properties.getBucketName(), key, inputStream, metadata);
PutObjectResult result = cosClient.putObject(request);
log.debug("COS upload success, key={}, requestId={}", key, result.getRequestId());
}
private void copySourceToTarget(String source, String sourceKey, String targetKey) {
if (isHttpUrl(source)) {
try {
uploadUrl(source, targetKey);
} catch (IOException ex) {
throw new IllegalStateException("copy remote object to COS failed", ex);
}
return;
}
CopyObjectRequest request = new CopyObjectRequest(properties.getBucketName(), sourceKey,
properties.getBucketName(), targetKey);
cosClient.copyObject(request);
}
private String toCosImageRule(String styleType) {
String style = StringUtils.trimToEmpty(styleType);
if (StringUtils.startsWith(style, COS_IMAGE_RULE_PREFIX)) {
return style;
}
if (StringUtils.startsWith(style, OSS_RESIZE_PREFIX)) {
String height = StringUtils.substringAfter(style, OSS_RESIZE_PREFIX);
if (StringUtils.isNumeric(height)) {
return "imageMogr2/thumbnail/x" + height;
}
}
return "imageMogr2/thumbnail/x1024";
}
private String getFilePath(String urlOrKey) {
if (StringUtils.isBlank(urlOrKey)) {
return urlOrKey;
}
String value = StringUtils.trim(urlOrKey);
if (!isHttpUrl(value)) {
return normalizeKey(StringUtils.substringBefore(value, "?"));
}
URI uri = URI.create(value);
return normalizeKey(uri.getPath());
}
private String normalizeKey(String key) {
return StringUtils.stripStart(StringUtils.substringBefore(StringUtils.trimToEmpty(key), "?"), "/");
}
private boolean isHttpUrl(String value) {
return StringUtils.startsWithAny(StringUtils.lowerCase(StringUtils.trimToEmpty(value)), "http://", "https://");
}
}

View File

@ -0,0 +1,19 @@
package com.red.circle.external.infra.oss;
import lombok.Builder;
import lombok.Value;
/**
* COS 存储公开配置响应.
*/
@Value
@Builder
public class TencentCosStsResponse {
String provider;
String bucketName;
String region;
String accessUrl;
String uploadMode;
}

View File

@ -0,0 +1,44 @@
package com.red.circle.external.infra.props;
import java.net.URI;
import java.util.Locale;
import lombok.AccessLevel;
import lombok.Data;
import lombok.experimental.FieldDefaults;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 腾讯云 COS 上传配置.
*/
@Data
@FieldDefaults(level = AccessLevel.PRIVATE)
@Component
@ConfigurationProperties(TencentCosProperties.PREFIX)
public class TencentCosProperties {
public static final String PREFIX = "red-circle.oss.tencent-cos";
String secretId;
String secretKey;
String bucketName;
String region;
String accessUrl;
public void validate() {
if (StringUtils.isAnyBlank(secretId, secretKey, bucketName, region, accessUrl)) {
throw new IllegalStateException(PREFIX + " config is incomplete");
}
URI accessUri = URI.create(accessUrl.trim());
String scheme = StringUtils.lowerCase(accessUri.getScheme(), Locale.ROOT);
if (!StringUtils.equalsAny(scheme, "http", "https") || StringUtils.isBlank(accessUri.getHost())) {
throw new IllegalStateException(PREFIX + ".access-url must be an absolute http url");
}
}
public String normalizedAccessUrl() {
return StringUtils.stripEnd(StringUtils.trimToEmpty(accessUrl), "/");
}
}

View File

@ -8,6 +8,7 @@ import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* oss 服务.
@ -27,6 +28,11 @@ public class OssServiceClientEndpoint implements OssServiceClientApi {
return ResultResponse.success(ossServiceClientService.sts());
}
@Override
public ResultResponse<String> upload(MultipartFile file, String key) {
return ResultResponse.success(ossServiceClientService.upload(file, key));
}
@Override
public ResultResponse<Void> remove(String url) {
ossServiceClientService.remove(url);

View File

@ -1,5 +1,7 @@
package com.red.circle.external.inner.service.oss;
import org.springframework.web.multipart.MultipartFile;
/**
* oss 服务api.
*
@ -12,6 +14,11 @@ public interface OssServiceClientService {
*/
Object sts();
/**
* 上传文件.
*/
String upload(MultipartFile file, String key);
/**
* 移除文件.
*/

View File

@ -1,10 +1,14 @@
package com.red.circle.external.inner.service.oss.impl;
import com.red.circle.component.oss.OssService;
import com.red.circle.component.oss.aliyun.service.AliYunStsService;
import com.red.circle.external.infra.oss.TencentCosCredentialService;
import com.red.circle.external.inner.service.oss.OssServiceClientService;
import java.io.IOException;
import java.io.InputStream;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
/**
* oss 服务api.
@ -16,11 +20,27 @@ import org.springframework.stereotype.Service;
public class OssServiceClientServiceImpl implements OssServiceClientService {
private final OssService ossService;
private final AliYunStsService aliYunStsService;
private final TencentCosCredentialService tencentCosCredentialService;
@Override
public Object sts() {
return aliYunStsService.getAcsResponse();
return tencentCosCredentialService.getResponse();
}
@Override
public String upload(MultipartFile file, String key) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("file is empty");
}
if (StringUtils.isBlank(key)) {
throw new IllegalArgumentException("file key is blank");
}
try (InputStream inputStream = file.getInputStream()) {
ossService.uploadInputStream(inputStream, key);
return ossService.getAccessUrl(key);
} catch (IOException ex) {
throw new IllegalStateException("upload file to object storage failed", ex);
}
}
@Override

View File

@ -1,5 +1,6 @@
package com.red.circle;
import com.red.circle.component.oss.OssAutoConfiguration;
import com.red.circle.component.redis.RedisAutoConfiguration;
import com.red.circle.framework.cloud.annotation.RedCircleCloudApplication;
import lombok.extern.slf4j.Slf4j;
@ -18,7 +19,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@EnableScheduling
@EnableDiscoveryClient
@EnableFeignClients
@SpringBootApplication(exclude = RedisAutoConfiguration.class)
@SpringBootApplication(exclude = {RedisAutoConfiguration.class, OssAutoConfiguration.class})
public class ExternalServiceApplication {
public static void main(String[] args) {

View File

@ -32,3 +32,12 @@ management:
rtc:
appId: f424387a480e41088239416e10030034
certificate: c96d8494f80542bb83c843ad4045df03
red-circle:
oss:
tencent-cos:
secret-id: ${TENCENT_COS_SECRET_ID:}
secret-key: ${TENCENT_COS_SECRET_KEY:}
bucket-name: ${TENCENT_COS_BUCKET_NAME:asset-global-interaction-1420526837}
region: ${TENCENT_COS_REGION:me-saudi-arabia}
access-url: ${TENCENT_COS_ACCESS_URL:https://asset.global-interaction.com}