Explorar o código

上传 datacenter 服务源码

Co-authored-by: Cursor <cursoragent@cursor.com>
jack hai 1 mes
pai
achega
dbcf409423
Modificáronse 64 ficheiros con 3778 adicións e 0 borrados
  1. 93 0
      datacenter/README.md
  2. 28 0
      datacenter/datacenter-entity/pom.xml
  3. 36 0
      datacenter/datacenter-entity/src/main/java/com/datacenter/entity/ExperienceGiftInviteGrantLog.java
  4. 52 0
      datacenter/datacenter-entity/src/main/java/com/datacenter/entity/ExperienceGiftRiskAlert.java
  5. 40 0
      datacenter/datacenter-entity/src/main/java/com/datacenter/entity/StatDailyAggregate.java
  6. 105 0
      datacenter/datacenter-entity/src/main/java/com/datacenter/entity/resp/MessageResult.java
  7. 71 0
      datacenter/datacenter-entity/src/main/java/com/datacenter/entity/stat/StatContractPnl.java
  8. 69 0
      datacenter/datacenter-entity/src/main/java/com/datacenter/entity/stat/StatCopyTradePosition.java
  9. 49 0
      datacenter/datacenter-entity/src/main/java/com/datacenter/entity/stat/StatPlatformDailyPnl.java
  10. 52 0
      datacenter/datacenter-entity/src/main/java/com/datacenter/entity/stat/StatTodayOverview.java
  11. 16 0
      datacenter/datacenter-entity/src/main/java/com/datacenter/entity/transform/AuthMember.java
  12. 76 0
      datacenter/datacenter-job/pom.xml
  13. 14 0
      datacenter/datacenter-job/src/main/java/com/datacenter/DatacenterApplication.java
  14. 42 0
      datacenter/datacenter-job/src/main/java/com/datacenter/config/WebMvcConfig.java
  15. 127 0
      datacenter/datacenter-job/src/main/java/com/datacenter/controller/JobController.java
  16. 38 0
      datacenter/datacenter-job/src/main/java/com/datacenter/controller/StatsController.java
  17. 33 0
      datacenter/datacenter-job/src/main/java/com/datacenter/interceptor/AuthenticationInterceptor.java
  18. 21 0
      datacenter/datacenter-job/src/main/java/com/datacenter/job/CopyTradePositionJob.java
  19. 38 0
      datacenter/datacenter-job/src/main/java/com/datacenter/job/ExperienceGiftExpireJob.java
  20. 38 0
      datacenter/datacenter-job/src/main/java/com/datacenter/job/ExperienceGiftInviteGrantJob.java
  21. 30 0
      datacenter/datacenter-job/src/main/java/com/datacenter/job/ExperienceGiftRiskJob.java
  22. 33 0
      datacenter/datacenter-job/src/main/java/com/datacenter/job/FollowerCleanupJob.java
  23. 33 0
      datacenter/datacenter-job/src/main/java/com/datacenter/job/PlatformDailyPnlJob.java
  24. 32 0
      datacenter/datacenter-job/src/main/java/com/datacenter/job/TodayOverviewJob.java
  25. 20 0
      datacenter/datacenter-job/src/main/java/com/datacenter/job/vo/FollowerCleanupCandidateVo.java
  26. 65 0
      datacenter/datacenter-job/src/main/java/com/datacenter/job/vo/InviteGrantCandidateVo.java
  27. 28 0
      datacenter/datacenter-job/src/main/java/com/datacenter/mapper/ExperienceGiftExpireMapper.java
  28. 83 0
      datacenter/datacenter-job/src/main/java/com/datacenter/mapper/ExperienceGiftInviteGrantMapper.java
  29. 49 0
      datacenter/datacenter-job/src/main/java/com/datacenter/mapper/ExperienceGiftRiskAlertMapper.java
  30. 32 0
      datacenter/datacenter-job/src/main/java/com/datacenter/mapper/FollowerCleanupMapper.java
  31. 103 0
      datacenter/datacenter-job/src/main/java/com/datacenter/mapper/StatContractPnlMapper.java
  32. 15 0
      datacenter/datacenter-job/src/main/java/com/datacenter/mapper/StatCopyTradePositionMapper.java
  33. 9 0
      datacenter/datacenter-job/src/main/java/com/datacenter/mapper/StatDailyAggregateMapper.java
  34. 143 0
      datacenter/datacenter-job/src/main/java/com/datacenter/mapper/StatPlatformDailyPnlMapper.java
  35. 135 0
      datacenter/datacenter-job/src/main/java/com/datacenter/mapper/StatTodayOverviewMapper.java
  36. 12 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/ExperienceGiftExpireService.java
  37. 22 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/ExperienceGiftInviteGrantService.java
  38. 16 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/ExperienceGiftRiskAlertService.java
  39. 11 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/FollowerCleanupService.java
  40. 18 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/StatContractPnlService.java
  41. 16 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/StatCopyTradePositionService.java
  42. 14 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/StatDailyAggregateService.java
  43. 17 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/StatPlatformDailyPnlService.java
  44. 33 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/StatTodayOverviewService.java
  45. 33 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/ExperienceGiftExpireServiceImpl.java
  46. 151 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/ExperienceGiftInviteGrantServiceImpl.java
  47. 56 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/ExperienceGiftRiskAlertServiceImpl.java
  48. 51 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/FollowerCleanupServiceImpl.java
  49. 230 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/StatContractPnlServiceImpl.java
  50. 188 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/StatCopyTradePositionServiceImpl.java
  51. 24 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/StatDailyAggregateServiceImpl.java
  52. 144 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/StatPlatformDailyPnlServiceImpl.java
  53. 102 0
      datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/StatTodayOverviewServiceImpl.java
  54. 42 0
      datacenter/datacenter-job/src/main/resources/application.yml
  55. 92 0
      datacenter/datacenter-job/src/main/resources/log4j2-spring.xml
  56. 112 0
      datacenter/datacenter-job/src/main/resources/mapper/ExperienceGiftInviteGrantMapper.xml
  57. 44 0
      datacenter/datacenter-job/src/main/resources/mapper/FollowerCleanupMapper.xml
  58. 103 0
      datacenter/datacenter-job/src/main/resources/mapper/StatCopyTradePositionMapper.xml
  59. 9 0
      datacenter/datacenter-job/src/main/resources/sql/experience_gift_invite_grant_log.sql
  60. 12 0
      datacenter/datacenter-job/src/main/resources/sql/experience_gift_risk_alert.sql
  61. 20 0
      datacenter/datacenter-job/src/main/resources/sql/stat_contract_pnl.sql
  62. 241 0
      datacenter/datacenter-job/src/main/resources/sql/stat_platform_daily_pnl_backfill_300.sql
  63. 18 0
      datacenter/datacenter-job/src/main/resources/sql/stat_today_overview.sql
  64. 99 0
      datacenter/pom.xml

+ 93 - 0
datacenter/README.md

@@ -0,0 +1,93 @@
+# datacenter
+
+
+
+## Getting started
+
+To make it easy for you to get started with GitLab, here's a list of recommended next steps.
+
+Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
+
+## Add your files
+
+* [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files
+* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
+
+```
+cd existing_repo
+git remote add origin http://044b9b313ae1/java/datacenter.git
+git branch -M main
+git push -uf origin main
+```
+
+## Integrate with your tools
+
+* [Set up project integrations](http://044b9b313ae1/java/datacenter/-/settings/integrations)
+
+## Collaborate with your team
+
+* [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/)
+* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/)
+* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically)
+* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/)
+* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
+
+## Test and Deploy
+
+Use the built-in continuous integration in GitLab.
+
+* [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/)
+* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/)
+* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/)
+* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/)
+* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
+
+***
+
+# Editing this README
+
+When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
+
+## Suggestions for a good README
+
+Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
+
+## Name
+Choose a self-explaining name for your project.
+
+## Description
+Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
+
+## Badges
+On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
+
+## Visuals
+Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
+
+## Installation
+Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
+
+## Usage
+Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
+
+## Support
+Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
+
+## Roadmap
+If you have ideas for releases in the future, it is a good idea to list them in the README.
+
+## Contributing
+State if you are open to contributions and what your requirements are for accepting them.
+
+For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
+
+You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
+
+## Authors and acknowledgment
+Show your appreciation to those who have contributed to the project.
+
+## License
+For open source projects, say how it is licensed.
+
+## Project status
+If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.

+ 28 - 0
datacenter/datacenter-entity/pom.xml

@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.datacenter</groupId>
+        <artifactId>datacenter</artifactId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>datacenter-entity</artifactId>
+    <packaging>jar</packaging>
+
+    <description>datacenter 实体与通用响应模型</description>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.exchange</groupId>
+            <artifactId>common-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.exchange</groupId>
+            <artifactId>common-mybatisPlus</artifactId>
+        </dependency>
+    </dependencies>
+</project>

+ 36 - 0
datacenter/datacenter-entity/src/main/java/com/datacenter/entity/ExperienceGiftInviteGrantLog.java

@@ -0,0 +1,36 @@
+package com.datacenter.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 体验金直邀发放去重日志
+ * UNIQUE KEY (position_id, product_id) 保证每个仓位对每个产品只触发一次发放
+ */
+@Data
+@TableName("experience_gift_invite_grant_log")
+public class ExperienceGiftInviteGrantLog {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 触发发放的合约仓位ID */
+    @TableField("position_id")
+    private Long positionId;
+
+    /** 体验金产品ID */
+    @TableField("product_id")
+    private Long productId;
+
+    /** 被发放体验金的邀请人ID */
+    @TableField("inviter_id")
+    private Long inviterId;
+
+    @TableField("created_at")
+    private Date createdAt;
+}

+ 52 - 0
datacenter/datacenter-entity/src/main/java/com/datacenter/entity/ExperienceGiftRiskAlert.java

@@ -0,0 +1,52 @@
+package com.datacenter.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 体验金防刷预警记录
+ */
+@Data
+@TableName("experience_gift_risk_alert")
+public class ExperienceGiftRiskAlert {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 预警用户ID */
+    @TableField("member_id")
+    private Long memberId;
+
+    /** 4h内领取体验金次数 */
+    @TableField("receipt_count")
+    private Integer receiptCount;
+
+    /** 4h内体验金平仓收益(USDT) */
+    @TableField("profit_amount")
+    private BigDecimal profitAmount;
+
+    /** 状态:0=告警,1=已取消告警 */
+    @TableField("status")
+    private Integer status;
+
+    /** 预警时间 */
+    @TableField("created_at")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createdAt;
+
+    /** 处理时间 */
+    @TableField("handled_at")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date handledAt;
+
+    /** 处理管理员ID */
+    @TableField("handled_by")
+    private Long handledBy;
+}

+ 40 - 0
datacenter/datacenter-entity/src/main/java/com/datacenter/entity/StatDailyAggregate.java

@@ -0,0 +1,40 @@
+package com.datacenter.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 日维度统计汇总表示例(需自建表 stat_daily_aggregate)。
+ */
+@Data
+@TableName("stat_daily_aggregate")
+public class StatDailyAggregate {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 业务日期 */
+    private LocalDate bizDate;
+
+    /** 指标编码,如 order_count、trade_amount */
+    private String metricKey;
+
+    /** 计数值 */
+    private Long countValue;
+
+    /** 金额类指标 */
+    private BigDecimal amountValue;
+
+    /** 扩展 JSON 或备注 */
+    private String extJson;
+
+    private LocalDateTime createdTime;
+
+    private LocalDateTime updatedTime;
+}

+ 105 - 0
datacenter/datacenter-entity/src/main/java/com/datacenter/entity/resp/MessageResult.java

@@ -0,0 +1,105 @@
+package com.datacenter.entity.resp;
+
+import com.alibaba.fastjson2.JSONObject;
+
+public class MessageResult<T> {
+    private T data;
+
+    public MessageResult(int code, String msg) {
+        this.code = code;
+        this.message = msg;
+    }
+
+    public MessageResult(int code, String msg, T object) {
+        this.code = code;
+        this.message = msg;
+        this.data = object;
+    }
+
+    public MessageResult() {
+    }
+
+    public MessageResult(JSONObject json) {
+        this.code = json.getInteger("code");
+        this.message = json.getString("message");
+        if (json.containsKey("data")) {
+            this.setData((T) json.get("data"));
+        }
+    }
+
+    public static MessageResult success() {
+        return new MessageResult(0, "SUCCESS");
+    }
+
+    public static MessageResult success(String msg) {
+        return new MessageResult(0, msg);
+    }
+
+    public static MessageResult success(String msg, Object data) {
+        return new MessageResult(0, msg, data);
+    }
+
+    public static MessageResult error(int code, String msg) {
+        return new MessageResult(code, msg);
+    }
+
+    public static MessageResult error(String msg) {
+        return new MessageResult(500, msg);
+    }
+
+    private int code;
+    private String message;
+    private String totalPage;
+    private String totalElement;
+
+    public String getTotalPage() {
+        return totalPage;
+    }
+
+    public void setTotalPage(String totalPage) {
+        this.totalPage = totalPage;
+    }
+
+    public String getTotalElement() {
+        return totalElement;
+    }
+
+    public void setTotalElement(String totalElement) {
+        this.totalElement = totalElement;
+    }
+
+    public int getCode() {
+        return code;
+    }
+
+    public void setCode(int code) {
+        this.code = code;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public void setMessage(String message) {
+        this.message = message;
+    }
+
+    @Override
+    public String toString() {
+        return JSONObject.toJSONString(this);
+    }
+
+    public Object getData() {
+        return data;
+    }
+
+    public void setData(T data) {
+        this.data = data;
+    }
+
+    public static MessageResult getSuccessInstance(String message, Object data) {
+        MessageResult result = success(message);
+        result.setData(data);
+        return result;
+    }
+}

+ 71 - 0
datacenter/datacenter-entity/src/main/java/com/datacenter/entity/stat/StatContractPnl.java

@@ -0,0 +1,71 @@
+package com.datacenter.entity.stat;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 合约盈亏统计表(自主合约 + 跟单合约)。
+ * Job 每次写两行:includeInternal=0(排除内部账户)和 includeInternal=1(含内部账户)。
+ */
+@Data
+@TableName("stat_contract_pnl")
+public class StatContractPnl {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 统计日期 */
+    private LocalDate statDate;
+
+    /** 是否包含内部账户:0=不含;1=含 */
+    private Integer includeInternal;
+
+    // ── 自主合约 ─────────────────────────────────────────────────────────────
+
+    /** 当前持仓盈利(USDT):所有自主持仓中浮盈大于0的总和 */
+    private BigDecimal selfHoldProfit;
+
+    /** 当前持仓亏损(USDT):所有自主持仓中浮亏绝对值的总和 */
+    private BigDecimal selfHoldLoss;
+
+    /** 今日平仓盈利(USDT):今日自主平仓 profit_and_loss > 0 的总和 */
+    private BigDecimal selfTodayCloseProfit;
+
+    /** 今日平仓亏损(USDT):今日自主平仓 profit_and_loss < 0 绝对值的总和 */
+    private BigDecimal selfTodayCloseLoss;
+
+    /** 平仓总盈利(USDT):历史累计自主平仓盈利总和 */
+    private BigDecimal selfTotalCloseProfit;
+
+    /** 平仓总亏损(USDT):历史累计自主平仓亏损绝对值总和 */
+    private BigDecimal selfTotalCloseLoss;
+
+    // ── 跟单合约 ─────────────────────────────────────────────────────────────
+
+    /** 当前持仓盈利(USDT):所有跟单持仓中浮盈大于0的总和 */
+    private BigDecimal copyHoldProfit;
+
+    /** 当前持仓亏损(USDT):所有跟单持仓中浮亏绝对值的总和 */
+    private BigDecimal copyHoldLoss;
+
+    /** 今日平仓盈利(USDT):今日跟单平仓 profit_and_loss > 0 的总和 */
+    private BigDecimal copyTodayCloseProfit;
+
+    /** 今日平仓亏损(USDT):今日跟单平仓 profit_and_loss < 0 绝对值的总和 */
+    private BigDecimal copyTodayCloseLoss;
+
+    /** 平仓总盈利(USDT):历史累计跟单平仓盈利总和 */
+    private BigDecimal copyTotalCloseProfit;
+
+    /** 平仓总亏损(USDT):历史累计跟单平仓亏损绝对值总和 */
+    private BigDecimal copyTotalCloseLoss;
+
+    /** 统计更新时间 */
+    private LocalDateTime updatedTime;
+}

+ 69 - 0
datacenter/datacenter-entity/src/main/java/com/datacenter/entity/stat/StatCopyTradePosition.java

@@ -0,0 +1,69 @@
+package com.datacenter.entity.stat;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("stat_copy_trade_position")
+public class StatCopyTradePosition {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private LocalDateTime statTime;
+    private Long traderMemberId;
+    private String traderLevel;
+    private String symbol;
+    private Integer direction;
+    private BigDecimal leverageRate;
+
+    /** 持仓量(USDT) */
+    private BigDecimal traderPosUsdt;
+    /** 带单员未实现盈亏(USDT) */
+    private BigDecimal traderUnrealizedPnl;
+    /** 收益率% */
+    private BigDecimal profitRate;
+
+    /** 普通跟单仓位数量 */
+    private Integer normalFollowCount;
+    /** 普通跟单仓位金额(USDT) */
+    private BigDecimal normalFollowPosUsdt;
+    /** 普通跟单未实现盈亏(USDT) */
+    private BigDecimal normalFollowPnl;
+
+    /** 无损跟单仓位数量 */
+    private Integer specialFollowCount;
+    /** 无损跟单仓位金额(USDT) */
+    private BigDecimal specialFollowPosUsdt;
+    /** 无损跟单未实现盈亏(USDT) */
+    private BigDecimal specialFollowPnl;
+
+    private LocalDateTime updatedTime;
+
+    // ── 以下字段仅用于计算,不写入数据库 ──────────────────────────────
+    /** 合约类型:0=USDT本位 1=币本位(COIN),对应 ContractMoneyType */
+    @TableField(exist = false)
+    private Integer moneyType;
+    @TableField(exist = false)
+    private BigDecimal shareNumber;
+    @TableField(exist = false)
+    private BigDecimal traderQty;
+    @TableField(exist = false)
+    private BigDecimal traderAvgOpen;
+    @TableField(exist = false)
+    private BigDecimal traderTotalPrincipal;
+    @TableField(exist = false)
+    private BigDecimal normalFollowQty;
+    @TableField(exist = false)
+    private BigDecimal normalFollowAvgOpen;
+    @TableField(exist = false)
+    private BigDecimal specialFollowQty;
+    @TableField(exist = false)
+    private BigDecimal specialFollowAvgOpen;
+}

+ 49 - 0
datacenter/datacenter-entity/src/main/java/com/datacenter/entity/stat/StatPlatformDailyPnl.java

@@ -0,0 +1,49 @@
+package com.datacenter.entity.stat;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 平台每日运营盈亏统计。
+ * 每天凌晨 1:05 跑定时任务,写两行:includeInternal=0(排除内部账户)和 includeInternal=1(含)。
+ */
+@Data
+@TableName("stat_platform_daily_pnl")
+public class StatPlatformDailyPnl {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private LocalDate statDate;
+    private Integer includeInternal;
+
+    // ── 充值 / 提现 ────────────────────────────────────────────────────────────
+    private BigDecimal depositAmount;
+    private BigDecimal withdrawAmount;
+
+    // ── 合约自主交易(follow_id IS NULL,含带单员自身交易)────────────────────────
+    private Integer    selfOpenCount;
+    private BigDecimal selfOpenAmount;
+    private BigDecimal selfCloseProfit;
+    private BigDecimal selfCloseLoss;
+    private BigDecimal selfCloseDiff;
+    private BigDecimal selfFee;
+    private BigDecimal selfFundingFee;
+
+    // ── 跟单交易(follow_id IS NOT NULL,仅跟单用户数据)────────────────────────
+    private Integer    copyOpenCount;
+    private BigDecimal copyOpenAmount;
+    private BigDecimal copyCloseProfit;
+    private BigDecimal copyCloseLoss;
+    private BigDecimal copyCloseDiff;
+    private BigDecimal copyFee;
+    private BigDecimal copyFundingFee;
+
+    private LocalDateTime updatedTime;
+}

+ 52 - 0
datacenter/datacenter-entity/src/main/java/com/datacenter/entity/stat/StatTodayOverview.java

@@ -0,0 +1,52 @@
+package com.datacenter.entity.stat;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+/**
+ * 今日概览统计表。
+ * 每次 job 执行会写两行:includeInternal=0(排除内部账户)和 includeInternal=1(含内部账户)。
+ */
+@Data
+@TableName("stat_today_overview")
+public class StatTodayOverview {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 统计日期 */
+    private LocalDate statDate;
+
+    /**
+     * 是否包含内部账户(member.is_internal=1)。
+     * 0=不含(默认展示);1=含("统计内部账户"开关打开时展示)
+     */
+    private Integer includeInternal;
+
+    /** 今日盈亏(USDT) = 平仓盈亏 + 实收手续费 + 资金费净额 */
+    private BigDecimal profitLoss;
+
+    /** 今日充值(USDT) */
+    private BigDecimal depositAmount;
+
+    /** 今日提现(USDT) */
+    private BigDecimal withdrawAmount;
+
+    /** 今日交易额(USDT):合约+跟单开仓金额 */
+    private BigDecimal tradeVolume;
+
+    /** 今日注册用户数 */
+    private Integer registerCount;
+
+    /** 今日登录用户数(按 member_id 去重) */
+    private Integer loginCount;
+
+    /** 统计更新时间 */
+    private LocalDateTime updatedTime;
+}

+ 16 - 0
datacenter/datacenter-entity/src/main/java/com/datacenter/entity/transform/AuthMember.java

@@ -0,0 +1,16 @@
+package com.datacenter.entity.transform;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+public class AuthMember implements Serializable {
+    private static final long serialVersionUID = -4199550203850153635L;
+    private long id;
+    private String name;
+    private String realName;
+    private String mobilePhone;
+    private String email;
+    private String authToken;
+}

+ 76 - 0
datacenter/datacenter-job/pom.xml

@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.datacenter</groupId>
+        <artifactId>datacenter</artifactId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>datacenter-job</artifactId>
+    <packaging>jar</packaging>
+
+    <description>datacenter 定时任务与数据统计</description>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.datacenter</groupId>
+            <artifactId>datacenter-entity</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.exchange</groupId>
+            <artifactId>common-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.exchange</groupId>
+            <artifactId>common-log</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.exchange</groupId>
+            <artifactId>common-mybatisPlus</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.exchange</groupId>
+            <artifactId>common-redis</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.exchange</groupId>
+            <artifactId>common-utils</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.baomidou</groupId>
+            <artifactId>dynamic-datasource-spring-boot-starter</artifactId>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <finalName>${project.name}</finalName>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.7.18</version>
+                <configuration>
+                    <mainClass>com.datacenter.DatacenterApplication</mainClass>
+                    <excludes>
+                        <exclude>
+                            <groupId>org.projectlombok</groupId>
+                            <artifactId>lombok</artifactId>
+                        </exclude>
+                    </excludes>
+                </configuration>
+                <executions>
+                    <execution>
+                        <id>repackage</id>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 14 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/DatacenterApplication.java

@@ -0,0 +1,14 @@
+package com.datacenter;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@EnableScheduling
+@SpringBootApplication
+public class DatacenterApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(DatacenterApplication.class, args);
+    }
+}

+ 42 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/config/WebMvcConfig.java

@@ -0,0 +1,42 @@
+package com.datacenter.config;
+
+import com.datacenter.interceptor.AuthenticationInterceptor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import javax.annotation.Resource;
+
+@Configuration
+public class WebMvcConfig implements WebMvcConfigurer {
+
+    @Resource
+    private AuthenticationInterceptor authenticationInterceptor;
+
+    private static final String[] EXCLUDE_PATHS = {
+            "/v2/api-docs",
+            "/swagger-resources",
+            "/swagger-resources/**",
+            "/configuration/ui",
+            "/configuration/security",
+            "/swagger-ui.html",
+            "/webjars/**",
+            "/error",
+            "/stats/ping",
+            "/job/run/**"
+    };
+
+    @Bean
+    public RestTemplate restTemplate() {
+        return new RestTemplate();
+    }
+
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        registry.addInterceptor(authenticationInterceptor)
+                .excludePathPatterns(EXCLUDE_PATHS)
+                .addPathPatterns("/**");
+    }
+}

+ 127 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/controller/JobController.java

@@ -0,0 +1,127 @@
+package com.datacenter.controller;
+
+import com.datacenter.entity.resp.MessageResult;
+import com.datacenter.entity.stat.StatContractPnl;
+import com.datacenter.entity.stat.StatPlatformDailyPnl;
+import com.datacenter.entity.stat.StatTodayOverview;
+import com.datacenter.service.StatContractPnlService;
+import com.datacenter.service.StatCopyTradePositionService;
+import com.datacenter.service.StatPlatformDailyPnlService;
+import com.datacenter.service.StatTodayOverviewService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 手动触发定时任务的内部接口,供 admin 管理后台"刷新"按钮调用。
+ * 路径已在 WebMvcConfig 中排除鉴权。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/job/run")
+@RequiredArgsConstructor
+public class JobController {
+
+    private final StatTodayOverviewService       statTodayOverviewService;
+    private final StatContractPnlService         statContractPnlService;
+    private final StatPlatformDailyPnlService    statPlatformDailyPnlService;
+    private final StatCopyTradePositionService   statCopyTradePositionService;
+
+    /**
+     * 立即执行今日概览统计任务(同时计算两个变体),返回指定模式的最新结果。
+     * POST /job/run/today-overview?includeInternal=0
+     */
+    @PostMapping("/today-overview")
+    public MessageResult runTodayOverview(
+            @RequestParam(value = "includeInternal", defaultValue = "0") int includeInternal) {
+        log.info("[JobController] 手动触发今日概览统计 includeInternal={}", includeInternal);
+        statTodayOverviewService.computeAndSaveBoth(LocalDate.now());
+        StatTodayOverview result = statTodayOverviewService.getByDate(LocalDate.now(), includeInternal == 1);
+        return MessageResult.success("SUCCESS", result);
+    }
+
+    /**
+     * 立即执行合约盈亏统计任务,返回指定模式的最新结果。
+     * POST /job/run/contract-pnl?includeInternal=0
+     */
+    @PostMapping("/contract-pnl")
+    public MessageResult runContractPnl(
+            @RequestParam(value = "includeInternal", defaultValue = "0") int includeInternal) {
+        log.info("[JobController] 手动触发合约盈亏统计 includeInternal={}", includeInternal);
+        statContractPnlService.computeAndSaveBoth(LocalDate.now());
+        StatContractPnl result = statContractPnlService.getByDate(LocalDate.now(), includeInternal == 1);
+        return MessageResult.success("SUCCESS", result);
+    }
+
+    /**
+     * 补算过去 N 天的历史概览数据(含充值提现),供 daily-recharge 图表使用。
+     * POST /job/run/today-overview/backfill?days=30
+     */
+    @PostMapping("/today-overview/backfill")
+    public MessageResult backfillTodayOverview(
+            @RequestParam(value = "days", defaultValue = "30") int days) {
+        log.info("[JobController] 回填历史概览统计 days={}", days);
+        statTodayOverviewService.backfill(days);
+        return MessageResult.success("SUCCESS", "已回填 " + days + " 天历史数据");
+    }
+
+    /**
+     * 补算过去 N 天的历史每日盈亏数据。
+     * POST /job/run/contract-pnl/backfill?days=30
+     */
+    @PostMapping("/contract-pnl/backfill")
+    public MessageResult backfillContractPnl(
+            @RequestParam(value = "days", defaultValue = "30") int days) {
+        log.info("[JobController] 回填历史合约盈亏统计 days={}", days);
+        statContractPnlService.backfill(days);
+        return MessageResult.success("SUCCESS", "已回填 " + days + " 天历史数据");
+    }
+
+    /**
+     * 立即执行平台每日盈亏统计(统计今日数据)。
+     * POST /job/run/platform-daily-pnl?includeInternal=0
+     */
+    @PostMapping("/platform-daily-pnl")
+    public MessageResult runPlatformDailyPnl(
+            @RequestParam(value = "includeInternal", defaultValue = "0") int includeInternal) {
+        log.info("[JobController] 手动触发平台每日盈亏统计 includeInternal={}", includeInternal);
+        statPlatformDailyPnlService.computeAndSaveBoth(LocalDate.now());
+        StatPlatformDailyPnl result = statPlatformDailyPnlService.getByDate(LocalDate.now(), includeInternal == 1);
+        return MessageResult.success("SUCCESS", result);
+    }
+
+    /**
+     * 补算过去 N 天的平台每日盈亏历史数据。
+     * POST /job/run/platform-daily-pnl/backfill?days=30
+     */
+    @PostMapping("/platform-daily-pnl/backfill")
+    public MessageResult backfillPlatformDailyPnl(
+            @RequestParam(value = "days", defaultValue = "30") int days) {
+        log.info("[JobController] 回填平台每日盈亏统计 days={}", days);
+        statPlatformDailyPnlService.backfill(days);
+        return MessageResult.success("SUCCESS", "已回填 " + days + " 天历史数据");
+    }
+
+    /**
+     * 立即执行跟单持仓快照统计。
+     * POST /job/run/copy-trade-position
+     * Body(可选): {"BTC/USDT": 95000.0, "ETH/USDT": 2316.97, ...}
+     */
+    @PostMapping("/copy-trade-position")
+    public MessageResult runCopyTradePosition(
+            @RequestBody(required = false) Map<String, BigDecimal> prices) {
+        log.info("[JobController] 手动触发跟单持仓快照统计 externalPrices={}",
+                prices != null ? prices.size() : 0);
+        if (prices != null && !prices.isEmpty()) {
+            statCopyTradePositionService.computeAndSave(prices);
+        } else {
+            statCopyTradePositionService.computeAndSave();
+        }
+        return MessageResult.success("SUCCESS", "跟单持仓快照已更新");
+    }
+}

+ 38 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/controller/StatsController.java

@@ -0,0 +1,38 @@
+package com.datacenter.controller;
+
+import com.datacenter.entity.StatDailyAggregate;
+import com.datacenter.entity.resp.MessageResult;
+import com.datacenter.service.StatDailyAggregateService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/stats")
+public class StatsController {
+
+    @Autowired
+    private StatDailyAggregateService statDailyAggregateService;
+
+    @GetMapping("/ping")
+    public MessageResult ping() {
+        Map<String, Object> body = new HashMap<>(2);
+        body.put("service", "datacenter");
+        body.put("role", "statistics");
+        return MessageResult.success("SUCCESS", body);
+    }
+
+    @GetMapping("/daily/list")
+    public MessageResult dailyList(
+            @RequestParam String metricKey,
+            @RequestParam(defaultValue = "30") int limitDays) {
+        List<StatDailyAggregate> list = statDailyAggregateService.listRecentByMetricKey(metricKey, limitDays);
+        return MessageResult.success("SUCCESS", list);
+    }
+}

+ 33 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/interceptor/AuthenticationInterceptor.java

@@ -0,0 +1,33 @@
+package com.datacenter.interceptor;
+
+import com.datacenter.entity.transform.AuthMember;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+@Slf4j
+@Component
+public class AuthenticationInterceptor implements HandlerInterceptor {
+
+    public static final String SESSION_MEMBER = "MEMBER";
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+        if (!(handler instanceof HandlerMethod)) {
+            return true;
+        }
+        log.info("sessionId={}", request.getSession().getId());
+        AuthMember authMember = (AuthMember) request.getSession().getAttribute(SESSION_MEMBER);
+        if (authMember == null) {
+            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+            response.setContentType("application/json;charset=UTF-8");
+            response.getWriter().write("{\"code\":4001,\"message\":\"未登录,请先登录\"}");
+            return false;
+        }
+        return true;
+    }
+}

+ 21 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/job/CopyTradePositionJob.java

@@ -0,0 +1,21 @@
+package com.datacenter.job;
+
+import com.datacenter.service.StatCopyTradePositionService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class CopyTradePositionJob {
+
+    private final StatCopyTradePositionService statCopyTradePositionService;
+
+    @Scheduled(cron = "0 5 * * * *")
+    public void run() {
+        log.info("[CopyTradePositionJob] 定时任务触发");
+        statCopyTradePositionService.computeAndSave();
+    }
+}

+ 38 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/job/ExperienceGiftExpireJob.java

@@ -0,0 +1,38 @@
+package com.datacenter.job;
+
+import com.datacenter.service.ExperienceGiftExpireService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 体验金到期失效定时任务
+ *
+ * <p>每半小时扫描一次,将所有已到期且未使用(status=0, expire_at &lt; NOW())
+ * 的体验金券批量置为已过期(status=4),并记录 closed_at 时间。</p>
+ *
+ * <p>说明:</p>
+ * <ul>
+ *   <li>仅处理 status=0(待使用)的券;</li>
+ *   <li>status=1(持仓中)到期的券需触发强制平仓,由交易引擎处理,不在本任务范围内。</li>
+ * </ul>
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ExperienceGiftExpireJob {
+
+    private final ExperienceGiftExpireService expireService;
+
+    @Scheduled(cron = "0 7,37 * * * ?")
+    public void run() {
+        log.info("体验金到期失效任务开始...");
+        try {
+            int count = expireService.expireUnusedRecords();
+            log.info("体验金到期失效任务完成,本次处理 {} 张", count);
+        } catch (Exception e) {
+            log.error("体验金到期失效任务异常", e);
+        }
+    }
+}

+ 38 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/job/ExperienceGiftInviteGrantJob.java

@@ -0,0 +1,38 @@
+package com.datacenter.job;
+
+import com.datacenter.service.ExperienceGiftInviteGrantService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 直邀赠送体验金定时任务
+ *
+ * <p>每半小时扫描一次过去 35 分钟内已完全平仓的合约仓位,
+ * 对满足直邀体验金产品条件(开仓金额 / 持仓时长 / 交易对 / 活动有效期)
+ * 且被邀请人有邀请关系的仓位,向邀请人发放对应体验金产品。</p>
+ *
+ * <p>去重机制:experience_gift_invite_grant_log 表的唯一键
+ * (position_id, product_id) 保证每个仓位对每个产品只触发一次发放,
+ * 任务重叠执行时通过 DuplicateKeyException 自动跳过。</p>
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ExperienceGiftInviteGrantJob {
+
+    private final ExperienceGiftInviteGrantService grantService;
+
+//    @Scheduled(cron = "0 3,33 * * * ?")
+    @Scheduled(cron = "0 */3 * * * *")
+    public void run() {
+        log.info("体验金直邀发放任务开始...");
+        try {
+            int count = grantService.checkAndGrantInviteGifts();
+            log.info("体验金直邀发放任务完成,本次发放 {} 张", count);
+        } catch (Exception e) {
+            log.error("体验金直邀发放任务异常", e);
+        }
+    }
+}

+ 30 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/job/ExperienceGiftRiskJob.java

@@ -0,0 +1,30 @@
+package com.datacenter.job;
+
+import com.datacenter.service.ExperienceGiftRiskAlertService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 体验金防刷风控定时任务
+ * 每 4 小时扫描一次,对满足刷返佣预警条件的用户生成预警记录
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ExperienceGiftRiskJob {
+
+    private final ExperienceGiftRiskAlertService riskAlertService;
+
+    @Scheduled(cron = "0 1 0,4,8,12,16,20 * * ?")
+    public void checkRisk() {
+        log.info("体验金防刷风控任务开始...");
+        try {
+            int count = riskAlertService.checkAndGenerateAlerts();
+            log.info("体验金防刷风控任务完成,新增预警 {} 条", count);
+        } catch (Exception e) {
+            log.error("体验金防刷风控任务异常", e);
+        }
+    }
+}

+ 33 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/job/FollowerCleanupJob.java

@@ -0,0 +1,33 @@
+package com.datacenter.job;
+
+import com.datacenter.service.FollowerCleanupService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+/**
+ * 跟单员余额不足清理定时任务
+ *
+ * <p>每天 0 点执行一次,扫描所有交易员的跟单员列表:
+ * 若跟单员账户余额 &lt; 交易员配置的最小跟单金额,且该跟单员当前无持仓,
+ * 则自动将其从该交易员的跟单列表中移除(follow.status=30)。</p>
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class FollowerCleanupJob {
+
+    private final FollowerCleanupService cleanupService;
+
+    @Scheduled(cron = "0 0 0 * * ?")
+    public void run() {
+        log.info("跟单员余额不足清理任务开始...");
+        try {
+            int count = cleanupService.checkAndRemoveFollowers();
+            log.info("跟单员余额不足清理任务完成,本次移除 {} 条跟随关系", count);
+        } catch (Exception e) {
+            log.error("跟单员余额不足清理任务异常", e);
+        }
+    }
+}

+ 33 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/job/PlatformDailyPnlJob.java

@@ -0,0 +1,33 @@
+package com.datacenter.job;
+
+import com.datacenter.service.StatPlatformDailyPnlService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+
+/**
+ * 平台每日盈亏统计定时任务。
+ * 每天凌晨 1:05 计算昨日数据(等待当天最后的结算任务完成后再跑)。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PlatformDailyPnlJob {
+
+    private final StatPlatformDailyPnlService statPlatformDailyPnlService;
+
+    @Scheduled(cron = "0 5 1 * * *")
+    public void run() {
+        LocalDate yesterday = LocalDate.now().minusDays(1);
+        log.info("[PlatformDailyPnlJob] 开始统计昨日平台盈亏 date={}", yesterday);
+        try {
+            statPlatformDailyPnlService.computeAndSaveBoth(yesterday);
+            log.info("[PlatformDailyPnlJob] 统计完成 date={}", yesterday);
+        } catch (Exception e) {
+            log.error("[PlatformDailyPnlJob] 统计失败 date={}", yesterday, e);
+        }
+    }
+}

+ 32 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/job/TodayOverviewJob.java

@@ -0,0 +1,32 @@
+package com.datacenter.job;
+
+import com.datacenter.service.StatContractPnlService;
+import com.datacenter.service.StatTodayOverviewService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+
+/**
+ * 今日概览定时任务 —— 每小时整点执行。
+ * 每次同时计算"不含内部账户"和"含内部账户"两个变体,写入:
+ *   - stat_today_overview(今日运营概览)
+ *   - stat_contract_pnl(合约盈亏统计)
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class TodayOverviewJob {
+
+    private final StatTodayOverviewService statTodayOverviewService;
+    private final StatContractPnlService   statContractPnlService;
+
+    @Scheduled(cron = "0 0 * * * *")
+    public void run() {
+        log.info("[TodayOverviewJob] 定时任务触发");
+        statTodayOverviewService.computeAndSaveBoth(LocalDate.now());
+        statContractPnlService.computeAndSaveBoth(LocalDate.now());
+    }
+}

+ 20 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/job/vo/FollowerCleanupCandidateVo.java

@@ -0,0 +1,20 @@
+package com.datacenter.job.vo;
+
+import lombok.Data;
+
+/**
+ * 跟单员清理候选数据
+ * 跟单余额不足且无持仓的跟随关系
+ */
+@Data
+public class FollowerCleanupCandidateVo {
+
+    /** 跟随关系ID(follow.id) */
+    private Long followId;
+
+    /** 跟单员ID(跟随者) */
+    private Long customerId;
+
+    /** 交易员ID */
+    private Long traderId;
+}

+ 65 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/job/vo/InviteGrantCandidateVo.java

@@ -0,0 +1,65 @@
+package com.datacenter.job.vo;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 直邀体验金发放候选数据
+ * 由主查询返回,包含一条"可触发发放"的合约仓位及对应产品信息
+ */
+@Data
+public class InviteGrantCandidateVo {
+
+    /** 合约仓位ID(触发来源) */
+    private Long positionId;
+
+    /** 开仓会员ID(被邀人) */
+    private Long memberId;
+
+    /** 邀请人ID(接收体验金的人) */
+    private Long inviterId;
+
+    /** 邀请人邮箱(用于发放成功后邮件通知) */
+    private String inviterEmail;
+
+    /** 仓位开仓价值(USDT),= total_position * open_price */
+    private BigDecimal openValue;
+
+    /** 实际持仓分钟数 */
+    private Integer holdMinutes;
+
+    /** 交易对符号,如 BTC/USDT */
+    private String coinSymbol;
+
+    // -------- 产品信息快照 --------
+
+    private Long productId;
+
+    /** 产品名称快照 */
+    private String productName;
+
+    /** 体验金面额(USDT) */
+    private BigDecimal amount;
+
+    /** 单张盈利上限(USDT) */
+    private BigDecimal maxProfitPerCard;
+
+    /** 有效期(天) */
+    private Integer validityDays;
+
+    /** 发放总量(0=不限) */
+    private Integer totalQuantity;
+
+    /** 已发放张数 */
+    private Integer issuedCount;
+
+    /** 单人领取上限 */
+    private Integer perUserLimit;
+
+    /** 杠杆倍数(来自产品交易对配置) */
+    private Integer leverage;
+
+    /** 被邀人注册 IP(用于写入 experience_gift_record.client_ip) */
+    private String clientIp;
+}

+ 28 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/mapper/ExperienceGiftExpireMapper.java

@@ -0,0 +1,28 @@
+package com.datacenter.mapper;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+import java.util.List;
+
+@Mapper
+public interface ExperienceGiftExpireMapper {
+
+    /**
+     * 查询所有已到期但未使用(status=0)的体验金券ID。
+     * 用于日志记录,方便排查哪些用户的券被置为过期。
+     */
+    @Select("SELECT id FROM experience_gift_record WHERE status = 0 AND expire_at < NOW()")
+    List<Long> findExpiredUnusedIds();
+
+    /**
+     * 批量将到期未使用(status=0, expire_at < NOW())的体验金券置为已过期(status=4)。
+     *
+     * @return 实际更新行数
+     */
+    @Update("UPDATE experience_gift_record " +
+            "SET status = 4, closed_at = NOW(), updated_at = NOW() " +
+            "WHERE status = 0 AND expire_at < NOW()")
+    int expireUnused();
+}

+ 83 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/mapper/ExperienceGiftInviteGrantMapper.java

@@ -0,0 +1,83 @@
+package com.datacenter.mapper;
+
+import com.datacenter.job.vo.InviteGrantCandidateVo;
+import org.apache.ibatis.annotations.*;
+
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+
+@Mapper
+public interface ExperienceGiftInviteGrantMapper {
+
+    /**
+     * 查询过去 35 分钟内已平仓、满足直邀发放条件、且尚未发放过的仓位候选列表。
+     * 详细 SQL 见 ExperienceGiftInviteGrantMapper.xml
+     */
+    List<InviteGrantCandidateVo> findQualifyingCandidates();
+
+    /** 查询邀请人在指定产品上已领取的体验金张数(用于 per_user_limit 检查) */
+    @Select("SELECT COUNT(*) FROM experience_gift_record " +
+            "WHERE member_id = #{inviterId} AND product_id = #{productId}")
+    int countGrantedByInviterAndProduct(@Param("inviterId") Long inviterId,
+                                        @Param("productId") Long productId);
+
+    /** 查询指定被邀人在指定产品上是否已触发过直邀发放(inviter_id 字段存的是被邀人 ID) */
+    @Select("SELECT COUNT(*) FROM experience_gift_record " +
+            "WHERE inviter_id = #{memberId} AND product_id = #{productId}")
+    int countGrantedByInviteeAndProduct(@Param("memberId") Long memberId,
+                                        @Param("productId") Long productId);
+
+    /** 查询产品当前已发放张数(事务内二次确认,防并发超发) */
+    @Select("SELECT issued_count FROM experience_gift_product WHERE id = #{productId}")
+    Integer getProductIssuedCount(@Param("productId") Long productId);
+
+    /** 插入体验金发放记录(memberId=邀请人,inviterId=被邀人) */
+    @Insert("INSERT INTO experience_gift_record " +
+            "(product_id, product_name, member_id, inviter_id, amount, max_profit, leverage, validity_days, " +
+            " grant_type, status, expire_at, client_ip, created_at, updated_at) " +
+            "VALUES " +
+            "(#{productId}, #{productName}, #{inviterId}, #{memberId}, #{amount}, #{maxProfitPerCard}, #{leverage}, " +
+            " #{validityDays}, 1, 0, #{expireAt}, #{clientIp}, #{now}, #{now})")
+    void insertRecord(@Param("productId") Long productId,
+                      @Param("productName") String productName,
+                      @Param("inviterId") Long inviterId,
+                      @Param("memberId") Long memberId,
+                      @Param("amount") BigDecimal amount,
+                      @Param("maxProfitPerCard") BigDecimal maxProfitPerCard,
+                      @Param("leverage") Integer leverage,
+                      @Param("validityDays") Integer validityDays,
+                      @Param("expireAt") Date expireAt,
+                      @Param("clientIp") String clientIp,
+                      @Param("now") Date now);
+
+    /** 初始化体验金钱包(已存在则忽略) */
+    @Insert("INSERT IGNORE INTO experience_gift_wallet " +
+            "(member_id, total_received, total_profit, pending_amount, frozen_amount, total_withdrawn, created_at, updated_at) " +
+            "VALUES (#{memberId}, 0, 0, 0, 0, 0, NOW(), NOW())")
+    void initWallet(@Param("memberId") Long memberId);
+
+    /** 累加钱包已领取金额 */
+    @Update("UPDATE experience_gift_wallet " +
+            "SET total_received = total_received + #{amount}, updated_at = NOW() " +
+            "WHERE member_id = #{memberId}")
+    void addReceivedToWallet(@Param("memberId") Long memberId,
+                             @Param("amount") BigDecimal amount);
+
+    /** 产品已发放张数 +1 */
+    @Update("UPDATE experience_gift_product " +
+            "SET issued_count = issued_count + 1, updated_at = NOW() " +
+            "WHERE id = #{productId}")
+    void incrementProductIssuedCount(@Param("productId") Long productId);
+
+    /**
+     * 插入去重日志,UNIQUE KEY(position_id, product_id)。
+     * 若已存在则抛 DuplicateKeyException,由调用方捕获并跳过。
+     */
+    @Insert("INSERT INTO experience_gift_invite_grant_log (position_id, product_id, inviter_id, created_at) " +
+            "VALUES (#{positionId}, #{productId}, #{inviterId}, #{createdAt})")
+    void insertGrantLog(@Param("positionId") Long positionId,
+                        @Param("productId") Long productId,
+                        @Param("inviterId") Long inviterId,
+                        @Param("createdAt") Date createdAt);
+}

+ 49 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/mapper/ExperienceGiftRiskAlertMapper.java

@@ -0,0 +1,49 @@
+package com.datacenter.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.datacenter.entity.ExperienceGiftRiskAlert;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Mapper
+public interface ExperienceGiftRiskAlertMapper extends BaseMapper<ExperienceGiftRiskAlert> {
+
+    /**
+     * 查询指定时间窗口内满足预警条件的用户统计数据:
+     *   a) 领取体验金次数 >= 5
+     *   b) 体验金平仓收益 >= 100U
+     */
+    @Select("SELECT r.member_id AS memberId," +
+            " COUNT(DISTINCT CASE WHEN r.created_at >= #{startStr} THEN r.id END) AS receiptCount," +
+            " COALESCE(SUM(CASE WHEN r.closed_at >= #{startStr} AND r.status = 2 AND r.pnl > 0 THEN r.pnl ELSE 0 END), 0) AS profitAmount" +
+            " FROM experience_gift_record r" +
+            " WHERE r.created_at >= #{startStr} OR (r.closed_at >= #{startStr} AND r.status = 2)" +
+            " GROUP BY r.member_id" +
+            " HAVING COUNT(DISTINCT CASE WHEN r.created_at >= #{startStr} THEN r.id END) >= 5" +
+            "     OR SUM(CASE WHEN r.closed_at >= #{startStr} AND r.status = 2 AND r.pnl > 0 THEN r.pnl ELSE 0 END) >= 100")
+    List<Map<String, Object>> findRiskStats(@Param("startStr") String startStr);
+
+    /** 检查某用户在指定时间之后是否已有指定状态的告警(防重复生成) */
+    @Select("SELECT COUNT(1) > 0 FROM experience_gift_risk_alert" +
+            " WHERE member_id = #{memberId} AND created_at >= #{createdAt} AND status = #{status}")
+    boolean existsByMemberIdAndCreatedAtAfterAndStatus(
+            @Param("memberId") Long memberId,
+            @Param("createdAt") Date createdAt,
+            @Param("status") Integer status);
+
+    /** 取消单条告警 */
+    @Update("UPDATE experience_gift_risk_alert SET status = 1, handled_at = #{now}, handled_by = #{operatorId}" +
+            " WHERE id = #{id} AND status = 0")
+    int cancelById(@Param("id") Long id, @Param("operatorId") Long operatorId, @Param("now") Date now);
+
+    /** 一键取消所有待处理告警 */
+    @Update("UPDATE experience_gift_risk_alert SET status = 1, handled_at = #{now}, handled_by = #{operatorId}" +
+            " WHERE status = 0")
+    int cancelAll(@Param("operatorId") Long operatorId, @Param("now") Date now);
+}

+ 32 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/mapper/FollowerCleanupMapper.java

@@ -0,0 +1,32 @@
+package com.datacenter.mapper;
+
+import com.baomidou.dynamic.datasource.annotation.DS;
+import com.datacenter.job.vo.FollowerCleanupCandidateVo;
+import org.apache.ibatis.annotations.*;
+
+import java.util.List;
+
+@Mapper
+@DS("copytrade")
+public interface FollowerCleanupMapper {
+
+    /**
+     * 查询需要清理的跟单员列表:
+     * 跟单余额 < 交易员配置的最小跟单金额,且当前无持仓(未平仓或部分平仓)。
+     * 详细 SQL 见 FollowerCleanupMapper.xml
+     */
+    List<FollowerCleanupCandidateVo> findCandidates();
+
+    /** 将跟随状态置为已移出(30 = 被交易员移出) */
+    @Update("UPDATE follow SET status = 30, updated_time = NOW() WHERE id = #{followId}")
+    void removeFollow(@Param("followId") Long followId);
+
+    /** 删除该跟随关系下的所有风险控制交易对 */
+    @Delete("DELETE FROM customer_follow_symbol " +
+            "WHERE risk_control_id IN (SELECT id FROM risk_control WHERE follow_id = #{followId})")
+    void deleteFollowSymbols(@Param("followId") Long followId);
+
+    /** 删除该跟随关系的风险控制记录 */
+    @Delete("DELETE FROM risk_control WHERE follow_id = #{followId}")
+    void deleteRiskControl(@Param("followId") Long followId);
+}

+ 103 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/mapper/StatContractPnlMapper.java

@@ -0,0 +1,103 @@
+package com.datacenter.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.datacenter.entity.stat.StatContractPnl;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 合约盈亏统计 Mapper。
+ *
+ * follow 参数含义:
+ *   0 = 自主合约(follow_position_id IS NULL / follow_id IS NULL)
+ *   1 = 跟单合约(follow_position_id IS NOT NULL / follow_id IS NOT NULL)
+ *
+ * ii = includeInternal:
+ *   0 = 排除 member.is_internal=1 的内部账户
+ *   1 = 全部账户
+ */
+@Mapper
+public interface StatContractPnlMapper extends BaseMapper<StatContractPnl> {
+
+    // ── 当前持仓原始数据(用于 Java 侧结合实时价格计算浮盈亏) ──────────────────
+
+    /**
+     * 返回所有 USDT 本位开仓的持仓行,每行包含 symbol、direction、open_price、avail_qty、share_number。
+     * 由 Service 结合实时价格在 Java 层计算浮盈/浮亏,避免 SQL 依赖 current_price(开仓时写入后不更新)。
+     */
+    @Select("SELECT p.member_id, c.symbol, p.direction, p.money_type, p.open_price, p.share_number, " +
+            "  GREATEST(p.total_position - p.closed_position, 0) AS avail_qty " +
+            "FROM member_contract_position p " +
+            "INNER JOIN contract_coin c ON p.contract_id = c.id " +
+            "WHERE p.status <> 2 " +
+            "  AND p.experience_record_id IS NULL " +
+            "  AND (#{follow} = 0 AND p.follow_position_id IS NULL OR #{follow} = 1 AND p.follow_position_id IS NOT NULL) " +
+            "  AND (#{ii} = 1 OR p.member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    List<Map<String, Object>> listOpenPositions(@Param("follow") int follow, @Param("ii") int ii);
+
+    // ── 今日平仓盈亏(contract_order_entrust) ────────────────────────────────
+
+    /**
+     * 今日平仓盈利:今日成交的平仓单中 profit_and_loss > 0 的总和。
+     * deal_time 为毫秒时间戳。
+     */
+    @Select("SELECT COALESCE(SUM(profit_and_loss), 0) " +
+            "FROM contract_order_entrust " +
+            "WHERE entrust_type = 1 AND status = 3 " +
+            "  AND profit_and_loss > 0 " +
+            "  AND experience_record_id IS NULL " +
+            "  AND deal_time >= #{startMs} AND deal_time < #{endMs} " +
+            "  AND (#{follow} = 0 AND follow_id IS NULL OR #{follow} = 1 AND follow_id IS NOT NULL) " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumTodayCloseProfit(@Param("follow") int follow,
+                                   @Param("startMs") long startMs,
+                                   @Param("endMs")   long endMs,
+                                   @Param("ii")      int ii);
+
+    /**
+     * 今日平仓亏损:今日成交的平仓单中 profit_and_loss < 0 绝对值的总和。
+     */
+    @Select("SELECT COALESCE(SUM(ABS(profit_and_loss)), 0) " +
+            "FROM contract_order_entrust " +
+            "WHERE entrust_type = 1 AND status = 3 " +
+            "  AND profit_and_loss < 0 " +
+            "  AND experience_record_id IS NULL " +
+            "  AND deal_time >= #{startMs} AND deal_time < #{endMs} " +
+            "  AND (#{follow} = 0 AND follow_id IS NULL OR #{follow} = 1 AND follow_id IS NOT NULL) " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumTodayCloseLoss(@Param("follow") int follow,
+                                 @Param("startMs") long startMs,
+                                 @Param("endMs")   long endMs,
+                                 @Param("ii")      int ii);
+
+    // ── 历史平仓盈亏(全量) ──────────────────────────────────────────────────
+
+    /**
+     * 历史累计平仓盈利(不限日期)。
+     */
+    @Select("SELECT COALESCE(SUM(profit_and_loss), 0) " +
+            "FROM contract_order_entrust " +
+            "WHERE entrust_type = 1 AND status = 3 " +
+            "  AND profit_and_loss > 0 " +
+            "  AND experience_record_id IS NULL " +
+            "  AND (#{follow} = 0 AND follow_id IS NULL OR #{follow} = 1 AND follow_id IS NOT NULL) " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumTotalCloseProfit(@Param("follow") int follow, @Param("ii") int ii);
+
+    /**
+     * 历史累计平仓亏损绝对值(不限日期)。
+     */
+    @Select("SELECT COALESCE(SUM(ABS(profit_and_loss)), 0) " +
+            "FROM contract_order_entrust " +
+            "WHERE entrust_type = 1 AND status = 3 " +
+            "  AND profit_and_loss < 0 " +
+            "  AND experience_record_id IS NULL " +
+            "  AND (#{follow} = 0 AND follow_id IS NULL OR #{follow} = 1 AND follow_id IS NOT NULL) " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumTotalCloseLoss(@Param("follow") int follow, @Param("ii") int ii);
+}

+ 15 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/mapper/StatCopyTradePositionMapper.java

@@ -0,0 +1,15 @@
+package com.datacenter.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.datacenter.entity.stat.StatCopyTradePosition;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Mapper
+public interface StatCopyTradePositionMapper extends BaseMapper<StatCopyTradePosition> {
+
+    List<StatCopyTradePosition> computeSnapshot(@Param("statTime") LocalDateTime statTime);
+}

+ 9 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/mapper/StatDailyAggregateMapper.java

@@ -0,0 +1,9 @@
+package com.datacenter.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.datacenter.entity.StatDailyAggregate;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface StatDailyAggregateMapper extends BaseMapper<StatDailyAggregate> {
+}

+ 143 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/mapper/StatPlatformDailyPnlMapper.java

@@ -0,0 +1,143 @@
+package com.datacenter.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.datacenter.entity.stat.StatPlatformDailyPnl;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 平台每日盈亏统计 Mapper。
+ * follow=0: 自主合约(follow_id IS NULL)
+ * follow=1: 跟单(follow_id IS NOT NULL)
+ * ii: includeInternal 0=排除内部账户 1=含
+ */
+@Mapper
+public interface StatPlatformDailyPnlMapper extends BaseMapper<StatPlatformDailyPnl> {
+
+    // ── 充值 / 提现(复用与 TodayOverview 相同的 SQL)────────────────────────────
+
+    @Select("SELECT COALESCE(SUM(amount), 0) " +
+            "FROM member_deposit " +
+            "WHERE create_time >= #{start} AND create_time < #{end} " +
+            "  AND unit LIKE '%USDT%' " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumDeposit(@Param("start") LocalDateTime start,
+                          @Param("end")   LocalDateTime end,
+                          @Param("ii")    int ii);
+
+    @Select("SELECT COALESCE(SUM(total_amount), 0) " +
+            "FROM withdraw_record " +
+            "WHERE status = 3 " +
+            "  AND deal_time >= #{start} AND deal_time < #{end} " +
+            "  AND coin_id LIKE '%USDT%' " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumWithdraw(@Param("start") LocalDateTime start,
+                           @Param("end")   LocalDateTime end,
+                           @Param("ii")    int ii);
+
+    // ── 开仓次数 / 开仓金额 ────────────────────────────────────────────────────
+
+    /**
+     * follow=0: follow_id IS NULL(自主合约,含带单员)
+     * follow=1: follow_id IS NOT NULL(跟单用户)
+     */
+    @Select("SELECT COUNT(*) " +
+            "FROM contract_order_entrust " +
+            "WHERE entrust_type = 0 AND status = 3 " +
+            "  AND experience_record_id IS NULL " +
+            "  AND (#{follow} = 0 AND follow_id IS NULL OR #{follow} = 1 AND follow_id IS NOT NULL) " +
+            "  AND deal_time >= #{startMs} AND deal_time < #{endMs} " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    int sumOpenCount(@Param("follow")  int follow,
+                     @Param("startMs") long startMs,
+                     @Param("endMs")   long endMs,
+                     @Param("ii")      int ii);
+
+    @Select("SELECT COALESCE(SUM(traded_volume * traded_price), 0) " +
+            "FROM contract_order_entrust " +
+            "WHERE entrust_type = 0 AND status = 3 " +
+            "  AND experience_record_id IS NULL " +
+            "  AND (#{follow} = 0 AND follow_id IS NULL OR #{follow} = 1 AND follow_id IS NOT NULL) " +
+            "  AND deal_time >= #{startMs} AND deal_time < #{endMs} " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumOpenAmount(@Param("follow")  int follow,
+                             @Param("startMs") long startMs,
+                             @Param("endMs")   long endMs,
+                             @Param("ii")      int ii);
+
+    // ── 平仓盈利(profit_and_loss > 0)────────────────────────────────────────
+
+    @Select("SELECT COALESCE(SUM(profit_and_loss), 0) " +
+            "FROM contract_order_entrust " +
+            "WHERE entrust_type = 1 AND status = 3 AND profit_and_loss > 0 " +
+            "  AND experience_record_id IS NULL " +
+            "  AND (#{follow} = 0 AND follow_id IS NULL OR #{follow} = 1 AND follow_id IS NOT NULL) " +
+            "  AND deal_time >= #{startMs} AND deal_time < #{endMs} " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumCloseProfit(@Param("follow")  int follow,
+                              @Param("startMs") long startMs,
+                              @Param("endMs")   long endMs,
+                              @Param("ii")      int ii);
+
+    // ── 平仓亏损(profit_and_loss < 0,返回正值)─────────────────────────────────
+
+    @Select("SELECT COALESCE(-SUM(profit_and_loss), 0) " +
+            "FROM contract_order_entrust " +
+            "WHERE entrust_type = 1 AND status = 3 AND profit_and_loss < 0 " +
+            "  AND experience_record_id IS NULL " +
+            "  AND (#{follow} = 0 AND follow_id IS NULL OR #{follow} = 1 AND follow_id IS NOT NULL) " +
+            "  AND deal_time >= #{startMs} AND deal_time < #{endMs} " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumCloseLoss(@Param("follow")  int follow,
+                            @Param("startMs") long startMs,
+                            @Param("endMs")   long endMs,
+                            @Param("ii")      int ii);
+
+    // ── 手续费(毛,扣返佣前)────────────────────────────────────────────────────
+
+    /**
+     * 用户侧支付的手续费(为负数账变,取反得平台毛收入)。
+     * type=19 CONTRACT_FEE(自主合约);type=37 FOLLOW_FEE(跟单)。
+     */
+    @Select("SELECT COALESCE(-SUM(amount), 0) " +
+            "FROM member_transaction " +
+            "WHERE type = #{feeType} " +
+            "  AND create_time >= #{start} AND create_time < #{end} " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumGrossFee(@Param("feeType") int feeType,
+                           @Param("start")   LocalDateTime start,
+                           @Param("end")     LocalDateTime end,
+                           @Param("ii")      int ii);
+
+    /**
+     * 代理/平级返佣总额(type∈{0,1,2}:OPEN/CLOSE/LEVEL),用于按比例分摊到自主和跟单。
+     */
+    @Select("SELECT COALESCE(SUM(num), 0) " +
+            "FROM contract_reward_record " +
+            "WHERE type IN (0, 1, 2) " +
+            "  AND create_time >= #{start} AND create_time < #{end} " +
+            "  AND (#{ii} = 1 OR from_member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumTotalRebates(@Param("start") LocalDateTime start,
+                               @Param("end")   LocalDateTime end,
+                               @Param("ii")    int ii);
+
+    // ── 资金费率净收入 ──────────────────────────────────────────────────────────
+
+    /**
+     * type=26 合约资金费(CONTRACT_OVERNIGHT_FEE);type=42 跟单资金费(FOLLOW_OVERNIGHT_FEE)。
+     * 用户侧为负账变,取反得平台净收入。
+     */
+    @Select("SELECT COALESCE(-SUM(amount), 0) " +
+            "FROM member_transaction " +
+            "WHERE type = #{fundingType} " +
+            "  AND create_time >= #{start} AND create_time < #{end} " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumFundingFee(@Param("fundingType") int fundingType,
+                             @Param("start")       LocalDateTime start,
+                             @Param("end")         LocalDateTime end,
+                             @Param("ii")          int ii);
+}

+ 135 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/mapper/StatTodayOverviewMapper.java

@@ -0,0 +1,135 @@
+package com.datacenter.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.datacenter.entity.stat.StatTodayOverview;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 今日概览统计 Mapper。
+ * ii = includeInternal (0 排除内部账户 / 1 含内部账户)
+ * 当 ii=0 时,通过 NOT IN 子查询排除 member.is_internal=1 的账户。
+ */
+@Mapper
+public interface StatTodayOverviewMapper extends BaseMapper<StatTodayOverview> {
+
+    // ── 今日盈亏 ──────────────────────────────────────────────────────────────
+
+    /**
+     * 平仓盈亏(平台视角)= -(用户平仓 P&L 之和)。
+     * 使用 deal_time(毫秒时间戳)过滤当日。
+     */
+    @Select("SELECT COALESCE(-SUM(profit_and_loss), 0) " +
+            "FROM contract_order_entrust " +
+            "WHERE entrust_type = 1 AND status = 3 " +
+            "  AND experience_record_id IS NULL " +
+            "  AND deal_time >= #{startMs} AND deal_time < #{endMs} " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumClosePl(@Param("startMs") long startMs,
+                          @Param("endMs")   long endMs,
+                          @Param("ii")      int ii);
+
+    /**
+     * 毛手续费(用户侧即时扣费):type=19 合约手续费,type=37 跟单手续费。
+     * 与定时批量任务无关,order 成交即写入,实时可查。
+     */
+    @Select("SELECT COALESCE(-SUM(amount), 0) " +
+            "FROM member_transaction " +
+            "WHERE type IN (19, 37) " +
+            "  AND create_time >= #{start} AND create_time < #{end} " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumGrossFee(@Param("start") LocalDateTime start,
+                           @Param("end")   LocalDateTime end,
+                           @Param("ii")    int ii);
+
+    /**
+     * 代理/平级返佣总额(type∈{0,1,2}),从毛手续费中扣除得到平台实到手续费。
+     * 统计表显示的 合约/跟单手续费 均已减去这部分,今日盈亏须保持一致。
+     */
+    @Select("SELECT COALESCE(SUM(num), 0) " +
+            "FROM contract_reward_record " +
+            "WHERE type IN (0, 1, 2) " +
+            "  AND create_time >= #{start} AND create_time < #{end} " +
+            "  AND (#{ii} = 1 OR from_member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumRebates(@Param("start") LocalDateTime start,
+                          @Param("end")   LocalDateTime end,
+                          @Param("ii")    int ii);
+
+
+/**
+     * 资金费净额(平台视角)= -(用户侧资金费账变之和)。
+     * type=26 合约资金费率;type=42 跟单资金费率。
+     */
+    @Select("SELECT COALESCE(-SUM(amount), 0) " +
+            "FROM member_transaction " +
+            "WHERE type IN (26, 42) " +
+            "  AND create_time >= #{start} AND create_time < #{end} " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumNetFundingFee(@Param("start") LocalDateTime start,
+                                @Param("end")   LocalDateTime end,
+                                @Param("ii")    int ii);
+
+    // ── 今日充值 ──────────────────────────────────────────────────────────────
+
+    @Select("SELECT COALESCE(SUM(amount), 0) " +
+            "FROM member_deposit " +
+            "WHERE create_time >= #{start} AND create_time < #{end} " +
+            "  AND unit LIKE '%USDT%' " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumDeposit(@Param("start") LocalDateTime start,
+                          @Param("end")   LocalDateTime end,
+                          @Param("ii")    int ii);
+
+    // ── 今日提现 ──────────────────────────────────────────────────────────────
+
+    /**
+     * status=3(SUCCESS);deal_time 为处理完成时间;coin_id 即 coin.name(字符串主键)。
+     */
+    @Select("SELECT COALESCE(SUM(total_amount), 0) " +
+            "FROM withdraw_record " +
+            "WHERE status = 3 " +
+            "  AND deal_time >= #{start} AND deal_time < #{end} " +
+            "  AND coin_id LIKE '%USDT%' " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumWithdraw(@Param("start") LocalDateTime start,
+                           @Param("end")   LocalDateTime end,
+                           @Param("ii")    int ii);
+
+    // ── 今日交易额 ────────────────────────────────────────────────────────────
+
+    /**
+     * 开仓交易额(USDT) = volume(开仓数量) × traded_price(成交价)。
+     * 含合约与跟单两个业务模块;entrust_type=0 开仓;status=3 成交;跟单单据同样写入此表,无需跨库。
+     */
+    @Select("SELECT COALESCE(SUM(traded_volume * traded_price), 0) " +
+            "FROM contract_order_entrust " +
+            "WHERE entrust_type = 0 AND status = 3 " +
+            "  AND deal_time >= #{startMs} AND deal_time < #{endMs} " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    BigDecimal sumTradeVolume(@Param("startMs") long startMs,
+                              @Param("endMs")   long endMs,
+                              @Param("ii")      int ii);
+
+    // ── 今日注册 / 登录 ────────────────────────────────────────────────────────
+
+    @Select("SELECT COUNT(*) " +
+            "FROM member " +
+            "WHERE registration_time >= #{start} AND registration_time < #{end} " +
+            "  AND (#{ii} = 1 OR COALESCE(is_internal, 0) != 1)")
+    int countRegister(@Param("start") LocalDateTime start,
+                      @Param("end")   LocalDateTime end,
+                      @Param("ii")    int ii);
+
+    @Select("SELECT COUNT(DISTINCT member_id) " +
+            "FROM member_operation_log " +
+            "WHERE operation_type = 'LOGIN' " +
+            "  AND create_time >= #{start} AND create_time < #{end} " +
+            "  AND (#{ii} = 1 OR member_id NOT IN (SELECT id FROM member WHERE is_internal = 1))")
+    int countLogin(@Param("start") LocalDateTime start,
+                   @Param("end")   LocalDateTime end,
+                   @Param("ii")    int ii);
+}

+ 12 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/ExperienceGiftExpireService.java

@@ -0,0 +1,12 @@
+package com.datacenter.service;
+
+public interface ExperienceGiftExpireService {
+
+    /**
+     * 扫描所有到期未使用(status=0, expire_at &lt; NOW())的体验金券,
+     * 批量置为已过期(status=4)。
+     *
+     * @return 本次处理的过期券数量
+     */
+    int expireUnusedRecords();
+}

+ 22 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/ExperienceGiftInviteGrantService.java

@@ -0,0 +1,22 @@
+package com.datacenter.service;
+
+import com.datacenter.job.vo.InviteGrantCandidateVo;
+
+public interface ExperienceGiftInviteGrantService {
+
+    /**
+     * 扫描满足条件的合约仓位,向邀请人发放直邀体验金。
+     *
+     * @return 本次实际发放的张数
+     */
+    int checkAndGrantInviteGifts();
+
+    /**
+     * 对单条候选记录执行发放(独立事务)。
+     * 通过 Spring 代理调用以保证事务生效;去重日志唯一键冲突时抛出异常由调用方处理。
+     */
+    /**
+     * @return true 表示本次实际完成了发放,false 表示因各种限制跳过
+     */
+    boolean processGrant(InviteGrantCandidateVo candidate);
+}

+ 16 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/ExperienceGiftRiskAlertService.java

@@ -0,0 +1,16 @@
+package com.datacenter.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.datacenter.entity.ExperienceGiftRiskAlert;
+
+public interface ExperienceGiftRiskAlertService extends IService<ExperienceGiftRiskAlert> {
+
+    /**
+     * 检查最近 4 小时内满足预警条件的用户,生成预警记录:
+     *   a) 4h 内领取体验金数量 >= 5
+     *   b) 4h 内体验金平仓收益 >= 100U
+     *
+     * @return 新增预警记录数
+     */
+    int checkAndGenerateAlerts();
+}

+ 11 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/FollowerCleanupService.java

@@ -0,0 +1,11 @@
+package com.datacenter.service;
+
+public interface FollowerCleanupService {
+
+    /**
+     * 扫描并移除余额不足且无持仓的跟单员。
+     *
+     * @return 本次移除的跟随关系数量
+     */
+    int checkAndRemoveFollowers();
+}

+ 18 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/StatContractPnlService.java

@@ -0,0 +1,18 @@
+package com.datacenter.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.datacenter.entity.stat.StatContractPnl;
+
+import java.time.LocalDate;
+
+public interface StatContractPnlService extends IService<StatContractPnl> {
+
+    StatContractPnl computeAndSave(LocalDate date, boolean includeInternal);
+
+    void computeAndSaveBoth(LocalDate date);
+
+    StatContractPnl getByDate(LocalDate date, boolean includeInternal);
+
+    /** 补算过去 days 天的历史数据(不含今天) */
+    void backfill(int days);
+}

+ 16 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/StatCopyTradePositionService.java

@@ -0,0 +1,16 @@
+package com.datacenter.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.datacenter.entity.stat.StatCopyTradePosition;
+
+import java.math.BigDecimal;
+import java.util.Map;
+
+public interface StatCopyTradePositionService extends IService<StatCopyTradePosition> {
+
+    /** 自行拉取价格后计算(定时任务入口) */
+    void computeAndSave();
+
+    /** 使用外部传入的价格计算(由 Admin 刷新时调用,避免 DNS 解析问题) */
+    void computeAndSave(Map<String, BigDecimal> externalPrices);
+}

+ 14 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/StatDailyAggregateService.java

@@ -0,0 +1,14 @@
+package com.datacenter.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.datacenter.entity.StatDailyAggregate;
+
+import java.util.List;
+
+public interface StatDailyAggregateService extends IService<StatDailyAggregate> {
+
+    /**
+     * 按指标查询最近若干天的日汇总,按业务日期倒序。
+     */
+    List<StatDailyAggregate> listRecentByMetricKey(String metricKey, int limitDays);
+}

+ 17 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/StatPlatformDailyPnlService.java

@@ -0,0 +1,17 @@
+package com.datacenter.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.datacenter.entity.stat.StatPlatformDailyPnl;
+
+import java.time.LocalDate;
+
+public interface StatPlatformDailyPnlService extends IService<StatPlatformDailyPnl> {
+
+    void computeAndSaveBoth(LocalDate date);
+
+    StatPlatformDailyPnl computeAndSave(LocalDate date, boolean includeInternal);
+
+    StatPlatformDailyPnl getByDate(LocalDate date, boolean includeInternal);
+
+    void backfill(int days);
+}

+ 33 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/StatTodayOverviewService.java

@@ -0,0 +1,33 @@
+package com.datacenter.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.datacenter.entity.stat.StatTodayOverview;
+
+import java.time.LocalDate;
+
+public interface StatTodayOverviewService extends IService<StatTodayOverview> {
+
+    /**
+     * 计算指定日期、指定内部账户模式的统计并写入(upsert)。
+     *
+     * @param date            目标日期
+     * @param includeInternal 是否包含 member.is_internal=1 的内部账户
+     * @return 写入后的统计记录
+     */
+    StatTodayOverview computeAndSave(LocalDate date, boolean includeInternal);
+
+    /**
+     * 便捷方法:同时计算两个变体(不含内部 + 含内部)。
+     */
+    void computeAndSaveBoth(LocalDate date);
+
+    /**
+     * 查询指定日期、指定模式的统计记录(可能为 null)。
+     */
+    StatTodayOverview getByDate(LocalDate date, boolean includeInternal);
+
+    /**
+     * 补算过去 N 天的历史概览数据(含充值提现),从昨天往前推 days 天。
+     */
+    void backfill(int days);
+}

+ 33 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/ExperienceGiftExpireServiceImpl.java

@@ -0,0 +1,33 @@
+package com.datacenter.service.impl;
+
+import com.datacenter.mapper.ExperienceGiftExpireMapper;
+import com.datacenter.service.ExperienceGiftExpireService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ExperienceGiftExpireServiceImpl implements ExperienceGiftExpireService {
+
+    private final ExperienceGiftExpireMapper expireMapper;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int expireUnusedRecords() {
+        // 先查询待过期的 ID 列表,用于日志追踪
+        List<Long> ids = expireMapper.findExpiredUnusedIds();
+        if (ids.isEmpty()) {
+            return 0;
+        }
+        log.info("体验金到期处理:共 {} 张待过期券,id={}", ids.size(), ids);
+
+        int updated = expireMapper.expireUnused();
+        log.info("体验金到期处理:成功置为已过期 {} 张", updated);
+        return updated;
+    }
+}

+ 151 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/ExperienceGiftInviteGrantServiceImpl.java

@@ -0,0 +1,151 @@
+package com.datacenter.service.impl;
+
+import com.datacenter.job.vo.InviteGrantCandidateVo;
+import com.datacenter.mapper.ExperienceGiftInviteGrantMapper;
+import com.datacenter.service.ExperienceGiftInviteGrantService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ExperienceGiftInviteGrantServiceImpl implements ExperienceGiftInviteGrantService {
+
+    private final ExperienceGiftInviteGrantMapper mapper;
+
+    @Resource
+    private RestTemplate restTemplate;
+
+    @Value("${admin.host}")
+    private String adminHost;
+
+    /**
+     * 通过 setter 注入自身代理,使 processGrant 上的 @Transactional 能被 Spring AOP 拦截。
+     * @Lazy 避免循环依赖问题。
+     */
+    @Autowired
+    @org.springframework.context.annotation.Lazy
+    private ExperienceGiftInviteGrantService self;
+
+    @Override
+    public int checkAndGrantInviteGifts() {
+        List<InviteGrantCandidateVo> candidates = mapper.findQualifyingCandidates();
+        if (candidates.isEmpty()) {
+            log.debug("体验金直邀发放:本次无满足条件的仓位");
+            return 0;
+        }
+        log.info("体验金直邀发放:查询到候选仓位 {} 条", candidates.size());
+
+        int granted = 0;
+        for (InviteGrantCandidateVo c : candidates) {
+            try {
+                boolean ok = self.processGrant(c);
+                if (ok) {
+                    granted++;
+                    log.info("体验金直邀发放成功:productId={} inviterId={} memberId={} positionId={} amount={}",
+                            c.getProductId(), c.getInviterId(), c.getMemberId(),
+                            c.getPositionId(), c.getAmount());
+                    notifyGrantEmail(c.getInviterId(), c.getAmount());
+                }
+            } catch (DuplicateKeyException e) {
+                // 去重日志唯一键冲突:并发任务已处理,跳过
+                log.warn("体验金直邀发放:仓位 {} 产品 {} 已处理,跳过",
+                        c.getPositionId(), c.getProductId());
+            } catch (Exception e) {
+                log.error("体验金直邀发放异常:positionId={} productId={} inviterId={}",
+                        c.getPositionId(), c.getProductId(), c.getInviterId(), e);
+            }
+        }
+        return granted;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean processGrant(InviteGrantCandidateVo c) {
+        // ① 事务内二次检查:同一被邀人在同一产品只触发一次(防并发重复处理多个仓位)
+        int inviteeTriggerCount = mapper.countGrantedByInviteeAndProduct(c.getMemberId(), c.getProductId());
+        if (inviteeTriggerCount > 0) {
+            log.debug("体验金直邀发放:被邀人 {} 已触发过产品 {} 的直邀发放,跳过",
+                    c.getMemberId(), c.getProductId());
+            return false;
+        }
+
+        // ② 事务内二次检查 per_user_limit(防并发超发)
+        int existingCount = mapper.countGrantedByInviterAndProduct(c.getInviterId(), c.getProductId());
+        if (existingCount >= c.getPerUserLimit()) {
+            log.debug("体验金直邀发放:邀请人 {} 已达产品 {} 领取上限 {},跳过",
+                    c.getInviterId(), c.getProductId(), c.getPerUserLimit());
+            return false;
+        }
+
+        // ③ 事务内二次检查 total_quantity(防并发超发)
+        if (c.getTotalQuantity() > 0) {
+            Integer latestIssued = mapper.getProductIssuedCount(c.getProductId());
+            if (latestIssued != null && latestIssued >= c.getTotalQuantity()) {
+                log.debug("体验金直邀发放:产品 {} 已达总量上限 {},跳过",
+                        c.getProductId(), c.getTotalQuantity());
+                return false;
+            }
+        }
+
+        Date now = new Date();
+        Date expireAt = new Date(now.getTime() + c.getValidityDays() * 24L * 60 * 60 * 1000);
+
+        // ④ 插入体验金发放记录(memberId=邀请人,inviterId=被邀人)
+        mapper.insertRecord(
+                c.getProductId(),
+                c.getProductName(),
+                c.getInviterId(),
+                c.getMemberId(),
+                c.getAmount(),
+                c.getMaxProfitPerCard(),
+                c.getLeverage(),
+                c.getValidityDays(),
+                expireAt,
+                c.getClientIp(),
+                now
+        );
+
+        // ⑤ 初始化钱包(INSERT IGNORE)并累加已领取金额
+        mapper.initWallet(c.getInviterId());
+        mapper.addReceivedToWallet(c.getInviterId(), c.getAmount());
+
+        // ⑥ 产品已发放张数 +1
+        mapper.incrementProductIssuedCount(c.getProductId());
+
+        // ⑦ 写入去重日志(UNIQUE KEY 冲突会抛 DuplicateKeyException,触发整体回滚)
+        mapper.insertGrantLog(c.getPositionId(), c.getProductId(), c.getInviterId(), now);
+        return true;
+    }
+
+    private void notifyGrantEmail(Long memberId, BigDecimal amount) {
+        try {
+            String url = adminHost + "/admin/noauth/experience-gift/grant-email";
+            Map<String, Object> body = new HashMap<>();
+            body.put("memberId", memberId);
+            body.put("amount", amount);
+            ResponseEntity<Map> resp = restTemplate.exchange(
+                    url, HttpMethod.POST, new HttpEntity<>(body), Map.class);
+            log.info("体验金直邀发放邮件通知完成:memberId={} amount={} response={}",
+                    memberId, amount, resp.getBody());
+        } catch (Exception e) {
+            log.warn("体验金直邀发放邮件通知失败:memberId={} amount={}", memberId, amount, e);
+        }
+    }
+}

+ 56 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/ExperienceGiftRiskAlertServiceImpl.java

@@ -0,0 +1,56 @@
+package com.datacenter.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.datacenter.entity.ExperienceGiftRiskAlert;
+import com.datacenter.mapper.ExperienceGiftRiskAlertMapper;
+import com.datacenter.service.ExperienceGiftRiskAlertService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class ExperienceGiftRiskAlertServiceImpl
+        extends ServiceImpl<ExperienceGiftRiskAlertMapper, ExperienceGiftRiskAlert>
+        implements ExperienceGiftRiskAlertService {
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int checkAndGenerateAlerts() {
+        Date now = new Date();
+        Date fourHoursAgo = new Date(now.getTime() - 4L * 60 * 60 * 1000);
+        String startStr = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(fourHoursAgo);
+
+        List<Map<String, Object>> rows = baseMapper.findRiskStats(startStr);
+
+        int count = 0;
+        for (Map<String, Object> row : rows) {
+            Long memberId = ((Number) row.get("memberId")).longValue();
+            int receiptCount = ((Number) row.get("receiptCount")).intValue();
+            BigDecimal profitAmount = new BigDecimal(row.get("profitAmount").toString());
+
+            // 同一用户在同一 4h 窗口内已有告警则跳过
+            if (baseMapper.existsByMemberIdAndCreatedAtAfterAndStatus(memberId, fourHoursAgo, 0)) {
+                log.debug("体验金风控:用户 {} 本周期已有告警,跳过", memberId);
+                continue;
+            }
+
+            ExperienceGiftRiskAlert alert = new ExperienceGiftRiskAlert();
+            alert.setMemberId(memberId);
+            alert.setReceiptCount(receiptCount);
+            alert.setProfitAmount(profitAmount);
+            alert.setStatus(0);
+            alert.setCreatedAt(now);
+            save(alert);
+            count++;
+            log.info("体验金风控:新增预警 memberId={} receiptCount={} profitAmount={}", memberId, receiptCount, profitAmount);
+        }
+        return count;
+    }
+}

+ 51 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/FollowerCleanupServiceImpl.java

@@ -0,0 +1,51 @@
+package com.datacenter.service.impl;
+
+import com.datacenter.job.vo.FollowerCleanupCandidateVo;
+import com.datacenter.mapper.FollowerCleanupMapper;
+import com.datacenter.service.FollowerCleanupService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class FollowerCleanupServiceImpl implements FollowerCleanupService {
+
+    private final FollowerCleanupMapper mapper;
+
+    @Override
+    public int checkAndRemoveFollowers() {
+        List<FollowerCleanupCandidateVo> candidates = mapper.findCandidates();
+        if (candidates.isEmpty()) {
+            log.debug("跟单员清理:本次无需移除的跟单员");
+            return 0;
+        }
+        log.info("跟单员清理:查询到候选记录 {} 条", candidates.size());
+
+        int removed = 0;
+        for (FollowerCleanupCandidateVo c : candidates) {
+            try {
+                removeOne(c);
+                removed++;
+                log.info("跟单员清理成功:followId={} customerId={} traderId={}",
+                        c.getFollowId(), c.getCustomerId(), c.getTraderId());
+            } catch (Exception e) {
+                log.error("跟单员清理异常:followId={} customerId={} traderId={}",
+                        c.getFollowId(), c.getCustomerId(), c.getTraderId(), e);
+            }
+        }
+        return removed;
+    }
+
+    @Transactional(rollbackFor = Exception.class)
+    public void removeOne(FollowerCleanupCandidateVo c) {
+        // ① 先删交易对(依赖 risk_control.id),再删风控,最后更新跟随状态
+        mapper.deleteFollowSymbols(c.getFollowId());
+        mapper.deleteRiskControl(c.getFollowId());
+        mapper.removeFollow(c.getFollowId());
+    }
+}

+ 230 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/StatContractPnlServiceImpl.java

@@ -0,0 +1,230 @@
+package com.datacenter.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.datacenter.entity.stat.StatContractPnl;
+import com.datacenter.mapper.StatContractPnlMapper;
+import com.datacenter.service.StatContractPnlService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class StatContractPnlServiceImpl
+        extends ServiceImpl<StatContractPnlMapper, StatContractPnl>
+        implements StatContractPnlService {
+
+    private static final int SELF = 0;   // 自主合约:follow_id IS NULL
+    private static final int COPY = 1;   // 跟单合约:follow_id IS NOT NULL
+
+    @Resource
+    private RestTemplate restTemplate;
+
+    @Value("${admin.host}")
+    private String adminHost;
+
+    @Override
+    public void computeAndSaveBoth(LocalDate date) {
+        computeAndSave(date, false);
+        computeAndSave(date, true);
+    }
+
+    @Override
+    public StatContractPnl computeAndSave(LocalDate date, boolean includeInternal) {
+        int ii = includeInternal ? 1 : 0;
+        log.info("[ContractPnl] 开始统计 date={} includeInternal={}", date, includeInternal);
+
+        Map<String, BigDecimal> prices = fetchPrices();
+        if (prices.isEmpty()) {
+            log.warn("[ContractPnl] 无法获取实时价格,持仓盈亏将跳过本次计算");
+        }
+
+        LocalDateTime dayStart = LocalDateTime.of(date, LocalTime.MIDNIGHT);
+        LocalDateTime dayEnd   = dayStart.plusDays(1);
+        long startMs = dayStart.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+        long endMs   = dayEnd.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+
+        // ── 自主合约 ────────────────────────────────────────────────────────
+        BigDecimal[] selfHold            = sumHoldPnl(baseMapper.listOpenPositions(SELF, ii), prices);
+        BigDecimal selfHoldProfit        = selfHold[0];
+        BigDecimal selfHoldLoss          = selfHold[1];
+        BigDecimal selfTodayCloseProfit  = baseMapper.sumTodayCloseProfit(SELF, startMs, endMs, ii);
+        BigDecimal selfTodayCloseLoss    = baseMapper.sumTodayCloseLoss(SELF, startMs, endMs, ii);
+        BigDecimal selfTotalCloseProfit  = baseMapper.sumTotalCloseProfit(SELF, ii);
+        BigDecimal selfTotalCloseLoss    = baseMapper.sumTotalCloseLoss(SELF, ii);
+
+        // ── 跟单合约 ────────────────────────────────────────────────────────
+        BigDecimal[] copyHold            = sumHoldPnl(baseMapper.listOpenPositions(COPY, ii), prices);
+        BigDecimal copyHoldProfit        = copyHold[0];
+        BigDecimal copyHoldLoss          = copyHold[1];
+        BigDecimal copyTodayCloseProfit  = baseMapper.sumTodayCloseProfit(COPY, startMs, endMs, ii);
+        BigDecimal copyTodayCloseLoss    = baseMapper.sumTodayCloseLoss(COPY, startMs, endMs, ii);
+        BigDecimal copyTotalCloseProfit  = baseMapper.sumTotalCloseProfit(COPY, ii);
+        BigDecimal copyTotalCloseLoss    = baseMapper.sumTotalCloseLoss(COPY, ii);
+
+        log.info("[ContractPnl] date={} ii={} " +
+                 "self: holdP={} holdL={} todayP={} todayL={} totalP={} totalL={} | " +
+                 "copy: holdP={} holdL={} todayP={} todayL={} totalP={} totalL={}",
+                 date, ii,
+                 selfHoldProfit, selfHoldLoss, selfTodayCloseProfit, selfTodayCloseLoss,
+                 selfTotalCloseProfit, selfTotalCloseLoss,
+                 copyHoldProfit, copyHoldLoss, copyTodayCloseProfit, copyTodayCloseLoss,
+                 copyTotalCloseProfit, copyTotalCloseLoss);
+
+        StatContractPnl entity = getByDate(date, includeInternal);
+        if (entity == null) {
+            entity = new StatContractPnl();
+            entity.setStatDate(date);
+            entity.setIncludeInternal(ii);
+        }
+        entity.setSelfHoldProfit(selfHoldProfit);
+        entity.setSelfHoldLoss(selfHoldLoss);
+        entity.setSelfTodayCloseProfit(selfTodayCloseProfit);
+        entity.setSelfTodayCloseLoss(selfTodayCloseLoss);
+        entity.setSelfTotalCloseProfit(selfTotalCloseProfit);
+        entity.setSelfTotalCloseLoss(selfTotalCloseLoss);
+        entity.setCopyHoldProfit(copyHoldProfit);
+        entity.setCopyHoldLoss(copyHoldLoss);
+        entity.setCopyTodayCloseProfit(copyTodayCloseProfit);
+        entity.setCopyTodayCloseLoss(copyTodayCloseLoss);
+        entity.setCopyTotalCloseProfit(copyTotalCloseProfit);
+        entity.setCopyTotalCloseLoss(copyTotalCloseLoss);
+        entity.setUpdatedTime(LocalDateTime.now());
+
+        saveOrUpdate(entity);
+        log.info("[ContractPnl] 写入完成 id={}", entity.getId());
+        return entity;
+    }
+
+    @Override
+    public void backfill(int days) {
+        if (days < 1)  days = 1;
+        if (days > 60) days = 60;
+        LocalDate today = LocalDate.now();
+        for (int i = days; i >= 1; i--) {
+            LocalDate date = today.minusDays(i);
+            log.info("[ContractPnl] 回填历史统计 date={}", date);
+            try {
+                computeAndSaveBoth(date);
+            } catch (Exception e) {
+                log.error("[ContractPnl] 回填失败 date={}", date, e);
+            }
+        }
+        log.info("[ContractPnl] 回填完成,共 {} 天", days);
+    }
+
+    @Override
+    public StatContractPnl getByDate(LocalDate date, boolean includeInternal) {
+        return getOne(new LambdaQueryWrapper<StatContractPnl>()
+                .eq(StatContractPnl::getStatDate, date)
+                .eq(StatContractPnl::getIncludeInternal, includeInternal ? 1 : 0));
+    }
+
+    /**
+     * 从 Admin 代理接口拉取实时价格,格式:symbol → price。
+     * 兼容 "ETH/USDT" 和 "ETHUSDT" 两种 key 格式(各存一份)。
+     */
+    private Map<String, BigDecimal> fetchPrices() {
+        try {
+            String url = adminHost + "/admin/noauth/swap-prices";
+            ParameterizedTypeReference<Map<String, BigDecimal>> typeRef =
+                    new ParameterizedTypeReference<Map<String, BigDecimal>>() {};
+            ResponseEntity<Map<String, BigDecimal>> resp =
+                    restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(null), typeRef);
+            if (resp.getBody() != null && !resp.getBody().isEmpty()) {
+                Map<String, BigDecimal> result = new java.util.HashMap<>(resp.getBody());
+                // 同时写入无斜杠格式,方便两种 symbol 风格都能命中
+                resp.getBody().forEach((k, v) -> result.put(k.replace("/", ""), v));
+                log.info("[ContractPnl] 获取到 {} 个交易对价格", resp.getBody().size());
+                return result;
+            }
+        } catch (Exception e) {
+            log.warn("[ContractPnl] 获取实时价格失败: {}", e.getMessage());
+        }
+        return Collections.emptyMap();
+    }
+
+    /**
+     * 用实时价格计算一组持仓的浮盈/浮亏合计。
+     * 先按 member_id 汇总每人净盈亏,再分桶:净盈→profit,净亏→loss。
+     * 与仓位管理页面保持一致(每人一行净值,不分正负分别累加)。
+     * 返回 [holdProfit, holdLoss],均为正数。
+     */
+    private BigDecimal[] sumHoldPnl(List<Map<String, Object>> positions, Map<String, BigDecimal> prices) {
+        log.info("[ContractPnl] sumHoldPnl 持仓数量={}", positions.size());
+
+        // 1. 计算每条持仓的 pnl,按 member_id 累加
+        Map<Long, BigDecimal> memberNetMap = new java.util.LinkedHashMap<>();
+        for (Map<String, Object> row : positions) {
+            Long memberId    = ((Number) row.get("member_id")).longValue();
+            String symbol    = (String) row.get("symbol");
+            int direction    = ((Number) row.get("direction")).intValue();
+            int moneyType    = ((Number) row.get("money_type")).intValue();
+            BigDecimal openP = toBD(row.get("open_price"));
+            BigDecimal qty   = toBD(row.get("avail_qty"));
+            BigDecimal share = toBD(row.get("share_number"));
+
+            BigDecimal curP = prices.getOrDefault(symbol,
+                              prices.getOrDefault(symbol.replace("/", ""), BigDecimal.ZERO));
+            if (curP.compareTo(BigDecimal.ZERO) <= 0 || openP.compareTo(BigDecimal.ZERO) <= 0) {
+                log.info("[ContractPnl]   跳过 member={} symbol={} dir={} mt={} openP={} curP={} qty={}",
+                         memberId, symbol, direction, moneyType, openP, curP, qty);
+                continue;
+            }
+
+            BigDecimal priceDiff = direction == 0
+                    ? curP.subtract(openP)
+                    : openP.subtract(curP);
+
+            BigDecimal pnl;
+            if (moneyType == 0) {
+                pnl = priceDiff.multiply(qty).multiply(share)
+                        .divide(openP, 8, RoundingMode.HALF_UP);
+            } else {
+                pnl = priceDiff.multiply(qty).setScale(8, RoundingMode.HALF_UP);
+            }
+
+            log.info("[ContractPnl]   member={} symbol={} dir={} mt={} openP={} curP={} qty={} share={} pnl={}",
+                     memberId, symbol, direction, moneyType, openP, curP, qty, share, pnl);
+
+            memberNetMap.merge(memberId, pnl, BigDecimal::add);
+        }
+
+        // 2. 按每人净值分桶(与仓位管理页一致:整人净盈亏,不拆分正负持仓)
+        BigDecimal profit = BigDecimal.ZERO;
+        BigDecimal loss   = BigDecimal.ZERO;
+        for (Map.Entry<Long, BigDecimal> entry : memberNetMap.entrySet()) {
+            BigDecimal net = entry.getValue();
+            log.info("[ContractPnl]   member={} net={}", entry.getKey(), net);
+            if (net.compareTo(BigDecimal.ZERO) > 0) {
+                profit = profit.add(net);
+            } else if (net.compareTo(BigDecimal.ZERO) < 0) {
+                loss = loss.add(net.abs());
+            }
+        }
+        return new BigDecimal[]{profit, loss};
+    }
+
+    private BigDecimal toBD(Object v) {
+        if (v == null) return BigDecimal.ZERO;
+        if (v instanceof BigDecimal) return (BigDecimal) v;
+        return new BigDecimal(v.toString());
+    }
+}

+ 188 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/StatCopyTradePositionServiceImpl.java

@@ -0,0 +1,188 @@
+package com.datacenter.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.datacenter.entity.stat.StatCopyTradePosition;
+import com.datacenter.mapper.StatCopyTradePositionMapper;
+import com.datacenter.service.StatCopyTradePositionService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class StatCopyTradePositionServiceImpl
+        extends ServiceImpl<StatCopyTradePositionMapper, StatCopyTradePosition>
+        implements StatCopyTradePositionService {
+
+    @Resource
+    private RestTemplate restTemplate;
+
+    @Value("${admin.host}")
+    private String adminHost;
+
+    @Override
+    public void computeAndSave() {
+        Map<String, BigDecimal> prices = fetchPrices();
+        if (prices.isEmpty()) {
+            log.error("[CopyTradePosition] 无法获取实时价格,本次快照已跳过");
+            return;
+        }
+        doComputeAndSave(prices);
+    }
+
+    @Override
+    public void computeAndSave(Map<String, BigDecimal> externalPrices) {
+        if (externalPrices == null || externalPrices.isEmpty()) {
+            log.warn("[CopyTradePosition] 外部价格为空,回退到自行拉取");
+            computeAndSave();
+            return;
+        }
+        doComputeAndSave(externalPrices);
+    }
+
+    private void doComputeAndSave(Map<String, BigDecimal> prices) {
+        LocalDateTime statTime = LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS);
+        log.info("[CopyTradePosition] 开始快照统计 statTime={}", statTime);
+
+        List<StatCopyTradePosition> rows = baseMapper.computeSnapshot(statTime);
+        log.info("[CopyTradePosition] 查询到 {} 条原始记录", rows.size());
+
+        for (StatCopyTradePosition row : rows) {
+            // 价格 Map 可能用 "ETHUSDT" 或 "ETH/USDT" 两种格式,都尝试
+            BigDecimal curPrice = prices.getOrDefault(row.getSymbol(),
+                    prices.getOrDefault(row.getSymbol().replace("/", ""), BigDecimal.ZERO));
+            if (curPrice.compareTo(BigDecimal.ZERO) == 0) {
+                log.warn("[CopyTradePosition] symbol={} 无法获取实时价格,跳过该行", row.getSymbol());
+                continue;
+            }
+            enrichRow(row, curPrice);
+        }
+        rows.removeIf(r -> r.getTraderPosUsdt() == null);
+
+        remove(new LambdaQueryWrapper<>());
+        if (!rows.isEmpty()) {
+            saveBatch(rows);
+        }
+        log.info("[CopyTradePosition] 快照写入完成,共 {} 条", rows.size());
+    }
+
+    private void enrichRow(StatCopyTradePosition row, BigDecimal curPrice) {
+        BigDecimal shareNum    = safe(row.getShareNumber());
+        BigDecimal traderQty   = safe(row.getTraderQty());
+        BigDecimal traderAvg   = safe(row.getTraderAvgOpen());
+        BigDecimal traderPri   = safe(row.getTraderTotalPrincipal());
+        int dir                = row.getDirection() != null ? row.getDirection() : 0;
+        // COIN type (money_type=1): qty is already in base coin, pnl = priceDiff * qty
+        // USDT type (money_type=0): qty is in contracts, pnl = priceDiff * qty * shareNum / avgOpen
+        boolean isCoin         = Integer.valueOf(1).equals(row.getMoneyType());
+
+        // 带单者
+        if (traderAvg.compareTo(BigDecimal.ZERO) > 0) {
+            BigDecimal posUsdt;
+            BigDecimal pnl;
+            BigDecimal priceDiff = dir == 0 ? curPrice.subtract(traderAvg) : traderAvg.subtract(curPrice);
+            if (isCoin) {
+                posUsdt = traderQty.multiply(curPrice).setScale(2, RoundingMode.HALF_UP);
+                pnl     = priceDiff.multiply(traderQty).setScale(2, RoundingMode.HALF_UP);
+            } else {
+                posUsdt = traderQty.multiply(shareNum).multiply(curPrice)
+                                   .divide(traderAvg, 2, RoundingMode.HALF_UP);
+                pnl     = priceDiff.multiply(traderQty).multiply(shareNum)
+                                   .divide(traderAvg, 2, RoundingMode.HALF_UP);
+            }
+            BigDecimal rate = traderPri.compareTo(BigDecimal.ZERO) > 0
+                    ? pnl.multiply(BigDecimal.valueOf(100)).divide(traderPri, 2, RoundingMode.HALF_UP)
+                    : BigDecimal.ZERO;
+            row.setTraderPosUsdt(posUsdt);
+            row.setTraderUnrealizedPnl(pnl);
+            row.setProfitRate(rate);
+        } else {
+            row.setTraderPosUsdt(BigDecimal.ZERO);
+            row.setTraderUnrealizedPnl(BigDecimal.ZERO);
+            row.setProfitRate(BigDecimal.ZERO);
+        }
+
+        // 普通跟单
+        BigDecimal normalQty = safe(row.getNormalFollowQty());
+        BigDecimal normalAvg = safe(row.getNormalFollowAvgOpen());
+        if (normalAvg.compareTo(BigDecimal.ZERO) > 0) {
+            BigDecimal priceDiff = dir == 0 ? curPrice.subtract(normalAvg) : normalAvg.subtract(curPrice);
+            BigDecimal posUsdt;
+            BigDecimal pnl;
+            if (isCoin) {
+                posUsdt = normalQty.multiply(curPrice).setScale(2, RoundingMode.HALF_UP);
+                pnl     = priceDiff.multiply(normalQty).setScale(2, RoundingMode.HALF_UP);
+            } else {
+                posUsdt = normalQty.multiply(shareNum).multiply(curPrice)
+                                   .divide(normalAvg, 2, RoundingMode.HALF_UP);
+                pnl     = priceDiff.multiply(normalQty).multiply(shareNum)
+                                   .divide(normalAvg, 2, RoundingMode.HALF_UP);
+            }
+            row.setNormalFollowPosUsdt(posUsdt);
+            row.setNormalFollowPnl(pnl);
+        } else {
+            row.setNormalFollowPosUsdt(BigDecimal.ZERO);
+            row.setNormalFollowPnl(BigDecimal.ZERO);
+        }
+
+        // 无损跟单
+        BigDecimal specialQty = safe(row.getSpecialFollowQty());
+        BigDecimal specialAvg = safe(row.getSpecialFollowAvgOpen());
+        if (specialAvg.compareTo(BigDecimal.ZERO) > 0) {
+            BigDecimal priceDiff = dir == 0 ? curPrice.subtract(specialAvg) : specialAvg.subtract(curPrice);
+            BigDecimal posUsdt;
+            BigDecimal pnl;
+            if (isCoin) {
+                posUsdt = specialQty.multiply(curPrice).setScale(2, RoundingMode.HALF_UP);
+                pnl     = priceDiff.multiply(specialQty).setScale(2, RoundingMode.HALF_UP);
+            } else {
+                posUsdt = specialQty.multiply(shareNum).multiply(curPrice)
+                                    .divide(specialAvg, 2, RoundingMode.HALF_UP);
+                pnl     = priceDiff.multiply(specialQty).multiply(shareNum)
+                                   .divide(specialAvg, 2, RoundingMode.HALF_UP);
+            }
+            row.setSpecialFollowPosUsdt(posUsdt);
+            row.setSpecialFollowPnl(pnl);
+        } else {
+            row.setSpecialFollowPosUsdt(BigDecimal.ZERO);
+            row.setSpecialFollowPnl(BigDecimal.ZERO);
+        }
+    }
+
+    private Map<String, BigDecimal> fetchPrices() {
+        // 调 Admin 的免鉴权代理接口(Admin 内部有 Eureka/Ribbon 可解析 contract-swap-api)
+        try {
+            String url = adminHost + "/admin/noauth/swap-prices";
+            ParameterizedTypeReference<Map<String, BigDecimal>> typeRef =
+                    new ParameterizedTypeReference<Map<String, BigDecimal>>() {};
+            ResponseEntity<Map<String, BigDecimal>> resp =
+                    restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(null), typeRef);
+            if (resp.getBody() != null && !resp.getBody().isEmpty()) {
+                log.info("[CopyTradePosition] 获取到 {} 个交易对价格", resp.getBody().size());
+                return resp.getBody();
+            }
+        } catch (Exception e) {
+            log.warn("[CopyTradePosition] 获取实时价格失败: {}", e.getMessage());
+        }
+        return Collections.emptyMap();
+    }
+
+    private BigDecimal safe(BigDecimal v) {
+        return v != null ? v : BigDecimal.ZERO;
+    }
+}

+ 24 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/StatDailyAggregateServiceImpl.java

@@ -0,0 +1,24 @@
+package com.datacenter.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.datacenter.entity.StatDailyAggregate;
+import com.datacenter.mapper.StatDailyAggregateMapper;
+import com.datacenter.service.StatDailyAggregateService;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class StatDailyAggregateServiceImpl extends ServiceImpl<StatDailyAggregateMapper, StatDailyAggregate>
+        implements StatDailyAggregateService {
+
+    @Override
+    public List<StatDailyAggregate> listRecentByMetricKey(String metricKey, int limitDays) {
+        int limit = Math.min(Math.max(limitDays, 1), 366);
+        return list(new LambdaQueryWrapper<StatDailyAggregate>()
+                .eq(StatDailyAggregate::getMetricKey, metricKey)
+                .orderByDesc(StatDailyAggregate::getBizDate)
+                .last("LIMIT " + limit));
+    }
+}

+ 144 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/StatPlatformDailyPnlServiceImpl.java

@@ -0,0 +1,144 @@
+package com.datacenter.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.datacenter.entity.stat.StatPlatformDailyPnl;
+import com.datacenter.mapper.StatPlatformDailyPnlMapper;
+import com.datacenter.service.StatPlatformDailyPnlService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+
+@Slf4j
+@Service
+public class StatPlatformDailyPnlServiceImpl
+        extends ServiceImpl<StatPlatformDailyPnlMapper, StatPlatformDailyPnl>
+        implements StatPlatformDailyPnlService {
+
+    // member_transaction type ordinals
+    private static final int TYPE_CONTRACT_FEE      = 19;
+    private static final int TYPE_FOLLOW_FEE        = 37;
+    private static final int TYPE_CONTRACT_FUNDING   = 26;
+    private static final int TYPE_FOLLOW_FUNDING     = 42;
+
+    @Override
+    public void computeAndSaveBoth(LocalDate date) {
+        computeAndSave(date, false);
+        computeAndSave(date, true);
+    }
+
+    @Override
+    public StatPlatformDailyPnl computeAndSave(LocalDate date, boolean includeInternal) {
+        int ii = includeInternal ? 1 : 0;
+        log.info("[PlatformDailyPnl] 开始统计 date={} includeInternal={}", date, includeInternal);
+
+        LocalDateTime dayStart = LocalDateTime.of(date, LocalTime.MIDNIGHT);
+        LocalDateTime dayEnd   = dayStart.plusDays(1);
+        long startMs = dayStart.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+        long endMs   = dayEnd.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+
+        // ── 充值 / 提现 ────────────────────────────────────────────────────────
+        BigDecimal depositAmount  = baseMapper.sumDeposit(dayStart, dayEnd, ii);
+        BigDecimal withdrawAmount = baseMapper.sumWithdraw(dayStart, dayEnd, ii);
+
+        // ── 自主合约(follow=0)────────────────────────────────────────────────
+        int        selfOpenCount  = baseMapper.sumOpenCount(0, startMs, endMs, ii);
+        BigDecimal selfOpenAmount = baseMapper.sumOpenAmount(0, startMs, endMs, ii);
+        BigDecimal selfCloseProfit = baseMapper.sumCloseProfit(0, startMs, endMs, ii);
+        BigDecimal selfCloseLoss   = baseMapper.sumCloseLoss(0, startMs, endMs, ii);
+        BigDecimal selfCloseDiff   = selfCloseLoss.subtract(selfCloseProfit);
+        BigDecimal selfFundingFee  = baseMapper.sumFundingFee(TYPE_CONTRACT_FUNDING, dayStart, dayEnd, ii);
+
+        // ── 跟单(follow=1)───────────────────────────────────────────────────
+        int        copyOpenCount  = baseMapper.sumOpenCount(1, startMs, endMs, ii);
+        BigDecimal copyOpenAmount = baseMapper.sumOpenAmount(1, startMs, endMs, ii);
+        BigDecimal copyCloseProfit = baseMapper.sumCloseProfit(1, startMs, endMs, ii);
+        BigDecimal copyCloseLoss   = baseMapper.sumCloseLoss(1, startMs, endMs, ii);
+        BigDecimal copyCloseDiff   = copyCloseLoss.subtract(copyCloseProfit);
+        BigDecimal copyFundingFee  = baseMapper.sumFundingFee(TYPE_FOLLOW_FUNDING, dayStart, dayEnd, ii);
+
+        // ── 手续费(毛收入按比例扣总返佣)─────────────────────────────────────────
+        BigDecimal grossSelfFee = baseMapper.sumGrossFee(TYPE_CONTRACT_FEE, dayStart, dayEnd, ii);
+        BigDecimal grossCopyFee = baseMapper.sumGrossFee(TYPE_FOLLOW_FEE,   dayStart, dayEnd, ii);
+        BigDecimal totalRebates = baseMapper.sumTotalRebates(dayStart, dayEnd, ii);
+
+        BigDecimal totalGross = grossSelfFee.add(grossCopyFee);
+        BigDecimal selfFee;
+        BigDecimal copyFee;
+        if (totalGross.compareTo(BigDecimal.ZERO) == 0) {
+            selfFee = BigDecimal.ZERO;
+            copyFee = BigDecimal.ZERO;
+        } else {
+            BigDecimal selfRebate = totalRebates
+                    .multiply(grossSelfFee)
+                    .divide(totalGross, 8, RoundingMode.HALF_UP);
+            selfFee = grossSelfFee.subtract(selfRebate);
+            copyFee = grossCopyFee.subtract(totalRebates.subtract(selfRebate));
+        }
+
+        log.info("[PlatformDailyPnl] date={} ii={} " +
+                 "deposit={} withdraw={} " +
+                 "selfOpen={}/{} selfPnl={}/{} selfFee={} selfFunding={} " +
+                 "copyOpen={}/{} copyPnl={}/{} copyFee={} copyFunding={}",
+                 date, ii,
+                 depositAmount, withdrawAmount,
+                 selfOpenCount, selfOpenAmount, selfCloseProfit, selfCloseLoss, selfFee, selfFundingFee,
+                 copyOpenCount, copyOpenAmount, copyCloseProfit, copyCloseLoss, copyFee, copyFundingFee);
+
+        StatPlatformDailyPnl entity = getByDate(date, includeInternal);
+        if (entity == null) {
+            entity = new StatPlatformDailyPnl();
+            entity.setStatDate(date);
+            entity.setIncludeInternal(ii);
+        }
+        entity.setDepositAmount(depositAmount);
+        entity.setWithdrawAmount(withdrawAmount);
+        entity.setSelfOpenCount(selfOpenCount);
+        entity.setSelfOpenAmount(selfOpenAmount);
+        entity.setSelfCloseProfit(selfCloseProfit);
+        entity.setSelfCloseLoss(selfCloseLoss);
+        entity.setSelfCloseDiff(selfCloseDiff);
+        entity.setSelfFee(selfFee);
+        entity.setSelfFundingFee(selfFundingFee);
+        entity.setCopyOpenCount(copyOpenCount);
+        entity.setCopyOpenAmount(copyOpenAmount);
+        entity.setCopyCloseProfit(copyCloseProfit);
+        entity.setCopyCloseLoss(copyCloseLoss);
+        entity.setCopyCloseDiff(copyCloseDiff);
+        entity.setCopyFee(copyFee);
+        entity.setCopyFundingFee(copyFundingFee);
+        entity.setUpdatedTime(LocalDateTime.now());
+
+        saveOrUpdate(entity);
+        log.info("[PlatformDailyPnl] 写入完成 id={}", entity.getId());
+        return entity;
+    }
+
+    @Override
+    public StatPlatformDailyPnl getByDate(LocalDate date, boolean includeInternal) {
+        return getOne(new LambdaQueryWrapper<StatPlatformDailyPnl>()
+                .eq(StatPlatformDailyPnl::getStatDate, date)
+                .eq(StatPlatformDailyPnl::getIncludeInternal, includeInternal ? 1 : 0));
+    }
+
+    @Override
+    public void backfill(int days) {
+        int n = Math.min(Math.max(days, 1), 90);
+        log.info("[PlatformDailyPnl] 开始回填历史数据 days={}", n);
+        for (int i = 1; i <= n; i++) {
+            LocalDate date = LocalDate.now().minusDays(i);
+            try {
+                computeAndSaveBoth(date);
+            } catch (Exception e) {
+                log.error("[PlatformDailyPnl] 回填失败 date={}", date, e);
+            }
+        }
+        log.info("[PlatformDailyPnl] 历史数据回填完成 days={}", n);
+    }
+}

+ 102 - 0
datacenter/datacenter-job/src/main/java/com/datacenter/service/impl/StatTodayOverviewServiceImpl.java

@@ -0,0 +1,102 @@
+package com.datacenter.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.datacenter.entity.stat.StatTodayOverview;
+import com.datacenter.mapper.StatTodayOverviewMapper;
+import com.datacenter.service.StatTodayOverviewService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+
+@Slf4j
+@Service
+public class StatTodayOverviewServiceImpl
+        extends ServiceImpl<StatTodayOverviewMapper, StatTodayOverview>
+        implements StatTodayOverviewService {
+
+    @Override
+    public void computeAndSaveBoth(LocalDate date) {
+        computeAndSave(date, false);
+        computeAndSave(date, true);
+    }
+
+    @Override
+    public StatTodayOverview computeAndSave(LocalDate date, boolean includeInternal) {
+        int ii = includeInternal ? 1 : 0;
+        log.info("[TodayOverview] 开始统计 date={} includeInternal={}", date, includeInternal);
+
+        LocalDateTime dayStart = LocalDateTime.of(date, LocalTime.MIDNIGHT);
+        LocalDateTime dayEnd   = dayStart.plusDays(1);
+        long startMs = dayStart.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+        long endMs   = dayEnd.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+
+        // ---- 今日盈亏 ----
+        // 实到手续费 = 毛手续费(type 19/37) - 代理返佣(type 0/1/2),与统计表口径一致
+        BigDecimal closePl     = baseMapper.sumClosePl(startMs, endMs, ii);
+        BigDecimal grossFee    = baseMapper.sumGrossFee(dayStart, dayEnd, ii);
+        BigDecimal rebates     = baseMapper.sumRebates(dayStart, dayEnd, ii);
+        BigDecimal platformFee = grossFee.subtract(rebates);
+        BigDecimal fundingFee  = baseMapper.sumNetFundingFee(dayStart, dayEnd, ii);
+        BigDecimal profitLoss  = closePl.add(platformFee).add(fundingFee);
+
+        // ---- 充值 / 提现 / 交易额 ----
+        BigDecimal depositAmount  = baseMapper.sumDeposit(dayStart, dayEnd, ii);
+        BigDecimal withdrawAmount = baseMapper.sumWithdraw(dayStart, dayEnd, ii);
+        BigDecimal tradeVolume    = baseMapper.sumTradeVolume(startMs, endMs, ii);
+
+        // ---- 注册 / 登录 ----
+        int registerCount = baseMapper.countRegister(dayStart, dayEnd, ii);
+        int loginCount    = baseMapper.countLogin(dayStart, dayEnd, ii);
+
+        log.info("[TodayOverview] date={} ii={} closePl={} grossFee={} rebates={} netFee={} funding={} " +
+                 "deposit={} withdraw={} tradeVol={} register={} login={}",
+                 date, ii, closePl, grossFee, rebates, platformFee, fundingFee,
+                 depositAmount, withdrawAmount, tradeVolume, registerCount, loginCount);
+
+        StatTodayOverview entity = getByDate(date, includeInternal);
+        if (entity == null) {
+            entity = new StatTodayOverview();
+            entity.setStatDate(date);
+            entity.setIncludeInternal(ii);
+        }
+        entity.setProfitLoss(profitLoss);
+        entity.setDepositAmount(depositAmount);
+        entity.setWithdrawAmount(withdrawAmount);
+        entity.setTradeVolume(tradeVolume);
+        entity.setRegisterCount(registerCount);
+        entity.setLoginCount(loginCount);
+        entity.setUpdatedTime(LocalDateTime.now());
+
+        saveOrUpdate(entity);
+        log.info("[TodayOverview] 写入完成 id={}", entity.getId());
+        return entity;
+    }
+
+    @Override
+    public StatTodayOverview getByDate(LocalDate date, boolean includeInternal) {
+        return getOne(new LambdaQueryWrapper<StatTodayOverview>()
+                .eq(StatTodayOverview::getStatDate, date)
+                .eq(StatTodayOverview::getIncludeInternal, includeInternal ? 1 : 0));
+    }
+
+    @Override
+    public void backfill(int days) {
+        int n = Math.min(Math.max(days, 1), 60);
+        log.info("[TodayOverview] 开始回填历史数据 days={}", n);
+        for (int i = 1; i <= n; i++) {
+            LocalDate date = LocalDate.now().minusDays(i);
+            try {
+                computeAndSaveBoth(date);
+            } catch (Exception e) {
+                log.error("[TodayOverview] 回填失败 date={}", date, e);
+            }
+        }
+        log.info("[TodayOverview] 历史数据回填完成 days={}", n);
+    }
+}

+ 42 - 0
datacenter/datacenter-job/src/main/resources/application.yml

@@ -0,0 +1,42 @@
+server:
+  port: 6030
+spring:
+  application:
+    name: datacenter
+  datasource:
+    dynamic:
+      primary: master
+      strict: false
+      datasource:
+        master:
+          driver-class-name: com.mysql.cj.jdbc.Driver
+          url: jdbc:mysql://172.31.39.212:3306/kucoin?characterEncoding=utf-8&serverTimezone=GMT%2B8&useSSL=false
+          username: kucoin
+          password: KCrbYZ2XcjBtxzdG
+        copytrade:
+          driver-class-name: com.mysql.cj.jdbc.Driver
+          url: jdbc:mysql://172.31.39.212:3306/copytrade?characterEncoding=utf-8&serverTimezone=GMT%2B8&useSSL=false
+          username: copytrade
+          password: idB2GX23z7pDnw5n
+  redis:
+    host: 172.31.39.212
+    port: 6379
+    password: redis123!
+    database: 0
+    timeout: 5000ms
+    lettuce:
+      pool:
+        max-active: 8
+        max-idle: 8
+        min-idle: 1
+        max-wait: 2000
+
+swap:
+  api:
+    host: http://contract-swap-api
+
+admin:
+  host: http://localhost:6010
+
+mybatis-plus:
+  mapper-locations: classpath*:mapper/**/*.xml

+ 92 - 0
datacenter/datacenter-job/src/main/resources/log4j2-spring.xml

@@ -0,0 +1,92 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<Configuration status="WARN" monitorInterval="30">
+    <Properties>
+        <Property name="app.name">${sys:APP_NAME:-${env:APP_NAME:-application}}</Property>
+        <Property name="log.path">${sys:LOG_PATH:-${env:LOG_PATH:-${sys:user.home}/logs}}</Property>
+        <Property name="log.roll.pattern">${sys:LOG_ROLL_PATTERN:-${env:LOG_ROLL_PATTERN:-yyyy-MM-dd}}</Property>
+        <Property name="LOG_PATTERN">%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n</Property>
+        <Property name="BYTEPLUS_TLS_ENDPOINT">${env:BYTEPLUS_TLS_ENDPOINT:-${env:VOLCENGINE_ENDPOINT:-}}</Property>
+        <Property name="BYTEPLUS_TLS_ACCESS_KEY_ID">${env:BYTEPLUS_TLS_ACCESS_KEY_ID:-${env:VOLCENGINE_ACCESS_KEY_ID:-}}</Property>
+        <Property name="BYTEPLUS_TLS_ACCESS_KEY_SECRET">${env:BYTEPLUS_TLS_ACCESS_KEY_SECRET:-${env:VOLCENGINE_ACCESS_KEY_SECRET:-}}</Property>
+        <Property name="BYTEPLUS_TLS_REGION">${env:BYTEPLUS_TLS_REGION:-${env:VOLCENGINE_REGION:-ap-southeast-1}}</Property>
+        <Property name="TLS_PROJECT_ID">${sys:byteplus.tls.projectId:-}</Property>
+        <Property name="TLS_TOPIC_ID">${sys:byteplus.tls.topicId:-}</Property>
+        <Property name="BYTEPLUS_TLS_BATCH_SIZE">${env:BYTEPLUS_TLS_BATCH_SIZE:-100}</Property>
+        <Property name="BYTEPLUS_TLS_FLUSH_INTERVAL_SECONDS">${env:BYTEPLUS_TLS_FLUSH_INTERVAL_SECONDS:-5}</Property>
+        <Property name="mybatis.sql.log.level">${sys:MYBATIS_SQL_LOG_LEVEL:-${env:MYBATIS_SQL_LOG_LEVEL:-INFO}}</Property>
+    </Properties>
+
+    <Appenders>
+        <Console name="Console" target="SYSTEM_OUT">
+            <PatternLayout pattern="${LOG_PATTERN}"/>
+        </Console>
+
+        <RollingFile name="RollingLogFile"
+                     filePattern="${log.path}/${app.name}/%d{${log.roll.pattern}}/%d{${log.roll.pattern}}-%i.log">
+            <PatternLayout pattern="${LOG_PATTERN}"/>
+            <Policies>
+                <TimeBasedTriggeringPolicy/>
+                <SizeBasedTriggeringPolicy size="200MB"/>
+            </Policies>
+            <DirectWriteRolloverStrategy maxFiles="10"/>
+        </RollingFile>
+
+        <Async name="AsyncRollingLogFile" bufferSize="262144">
+            <AppenderRef ref="RollingLogFile"/>
+        </Async>
+
+        <BytePlusTLS name="BytePlusTLS"
+                     endpoint="${BYTEPLUS_TLS_ENDPOINT}"
+                     accessKeyId="${BYTEPLUS_TLS_ACCESS_KEY_ID}"
+                     accessKeySecret="${BYTEPLUS_TLS_ACCESS_KEY_SECRET}"
+                     region="${BYTEPLUS_TLS_REGION}"
+                     projectId="${TLS_PROJECT_ID}"
+                     topicId="${TLS_TOPIC_ID}"
+                     batchSize="${BYTEPLUS_TLS_BATCH_SIZE}"
+                     flushIntervalSeconds="${BYTEPLUS_TLS_FLUSH_INTERVAL_SECONDS}">
+            <ThresholdFilter level="INFO" onMatch="ACCEPT" onMismatch="DENY"/>
+        </BytePlusTLS>
+    </Appenders>
+
+    <Loggers>
+        <Logger name="org.springframework" level="INFO" additivity="false">
+            <AppenderRef ref="Console"/>
+            <AppenderRef ref="AsyncRollingLogFile"/>
+        </Logger>
+        <Logger name="com.alibaba.nacos" level="INFO" additivity="false">
+            <AppenderRef ref="Console"/>
+            <AppenderRef ref="AsyncRollingLogFile"/>
+        </Logger>
+        <Logger name="com.volcengine" level="INFO" additivity="false">
+            <AppenderRef ref="Console"/>
+            <AppenderRef ref="AsyncRollingLogFile"/>
+        </Logger>
+        <Logger name="com.exchange.log.appender" level="INFO" additivity="false">
+            <AppenderRef ref="Console"/>
+            <AppenderRef ref="AsyncRollingLogFile"/>
+        </Logger>
+        <Logger name="org.apache.ibatis" level="${mybatis.sql.log.level}" additivity="false">
+            <AppenderRef ref="Console"/>
+            <AppenderRef ref="AsyncRollingLogFile"/>
+        </Logger>
+        <Logger name="org.apache.ibatis.logging.jdbc" level="${mybatis.sql.log.level}" additivity="false">
+            <AppenderRef ref="Console"/>
+            <AppenderRef ref="AsyncRollingLogFile"/>
+        </Logger>
+        <Logger name="com.exchange" level="${mybatis.sql.log.level}" additivity="false">
+            <AppenderRef ref="Console"/>
+            <AppenderRef ref="AsyncRollingLogFile"/>
+            <AppenderRef ref="BytePlusTLS"/>
+        </Logger>
+        <Logger name="com.datacenter" level="${mybatis.sql.log.level}" additivity="false">
+            <AppenderRef ref="Console"/>
+            <AppenderRef ref="AsyncRollingLogFile"/>
+            <AppenderRef ref="BytePlusTLS"/>
+        </Logger>
+        <Root level="INFO">
+            <AppenderRef ref="Console"/>
+            <AppenderRef ref="AsyncRollingLogFile"/>
+        </Root>
+    </Loggers>
+</Configuration>

+ 112 - 0
datacenter/datacenter-job/src/main/resources/mapper/ExperienceGiftInviteGrantMapper.xml

@@ -0,0 +1,112 @@
+<?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.datacenter.mapper.ExperienceGiftInviteGrantMapper">
+
+    <resultMap id="candidateMap" type="com.datacenter.job.vo.InviteGrantCandidateVo">
+        <result column="positionId"      property="positionId"/>
+        <result column="memberId"        property="memberId"/>
+        <result column="inviterId"       property="inviterId"/>
+        <result column="inviterEmail"    property="inviterEmail"/>
+        <result column="openValue"       property="openValue"/>
+        <result column="holdMinutes"     property="holdMinutes"/>
+        <result column="coinSymbol"      property="coinSymbol"/>
+        <result column="productId"       property="productId"/>
+        <result column="productName"     property="productName"/>
+        <result column="amount"          property="amount"/>
+        <result column="maxProfitPerCard" property="maxProfitPerCard"/>
+        <result column="validityDays"    property="validityDays"/>
+        <result column="totalQuantity"   property="totalQuantity"/>
+        <result column="issuedCount"     property="issuedCount"/>
+        <result column="perUserLimit"    property="perUserLimit"/>
+        <result column="leverage"        property="leverage"/>
+        <result column="clientIp"        property="clientIp"/>
+    </resultMap>
+
+    <!--
+        扫描满足直邀发放条件的合约仓位,无论是否已平仓:
+
+        【持仓中】status IN (0,1):用 NOW() 计算已持仓时长,达到产品配置的 min_hold_minutes 即可触发。
+        【已平仓】status = 2:用实际平仓时长(close_time - open_time)判断,无额外时间窗口限制。
+                              数据范围由产品 activity_start_time JOIN 条件自然收敛,
+                              NOT EXISTS 去重日志保证每个仓位仅处理一次。
+
+        其他条件:
+          - 不限保证金类型(money_type),交易对已由 coin_symbol JOIN 限定
+          - total_position * open_price 为开仓价值,需 >= min_open_value
+          - 被邀人有邀请关系(member.inviter_id IS NOT NULL)
+          - 被邀人注册时间必须在活动开始时间之后(registration_time >= activity_start_time,无结束时间限制)
+          - 体验金产品:status=1(启用)、grant_type=1(系统触发)、trigger_type=1(直邀赠送)
+          - 活动已开始:activity_start_time <= NOW()(无结束时间限制)
+          - total_quantity=0 表示不限量;否则 issued_count < total_quantity
+          - NOT EXISTS 去重(唯一键 position_id + product_id)
+          - 同一被邀人在同一产品下只触发一次(不管开多少仓位)
+    -->
+    <select id="findQualifyingCandidates" resultMap="candidateMap">
+        SELECT
+            p.id                                                              AS positionId,
+            p.member_id                                                       AS memberId,
+            m.inviter_id                                                      AS inviterId,
+            mi.email                                                          AS inviterEmail,
+            (p.total_position * p.open_price)                                 AS openValue,
+            TIMESTAMPDIFF(MINUTE, p.open_time, IFNULL(p.close_time, NOW()))   AS holdMinutes,
+            cc.symbol                                                         AS coinSymbol,
+            egp.id                                                            AS productId,
+            egp.name                                                          AS productName,
+            egp.amount                                                        AS amount,
+            egp.max_profit_per_card                                           AS maxProfitPerCard,
+            egp.validity_days                                                 AS validityDays,
+            egp.total_quantity                                                AS totalQuantity,
+            egp.issued_count                                                  AS issuedCount,
+            egp.per_user_limit                                                AS perUserLimit,
+            egpc.leverage                                                     AS leverage,
+            m.registration_ip                                                 AS clientIp
+        FROM member_contract_position p
+        INNER JOIN member m
+            ON m.id = p.member_id
+            AND m.inviter_id IS NOT NULL
+        INNER JOIN member mi
+            ON mi.id = m.inviter_id
+        INNER JOIN contract_coin cc
+            ON cc.id = p.contract_id
+        INNER JOIN experience_gift_product_coin egpc
+            ON egpc.coin_symbol = cc.symbol
+        INNER JOIN experience_gift_product egp
+            ON egp.id            = egpc.product_id
+            AND egp.status       = 1
+            AND egp.grant_type   = 1
+            AND egp.trigger_type = 1
+            AND egp.activity_start_time &lt;= NOW()
+            AND p.open_time             &gt;= egp.activity_start_time
+            AND (p.total_position * p.open_price) &gt;= egp.min_open_value
+            AND TIMESTAMPDIFF(MINUTE, p.open_time, IFNULL(p.close_time, NOW())) &gt;= egp.min_hold_minutes
+            AND (egp.total_quantity = 0 OR egp.issued_count &lt; egp.total_quantity)
+        WHERE p.status IN (0, 1, 2)
+          and p.follow_position_id is null
+          and p.experience_record_id is null
+          AND m.registration_time &gt;= egp.activity_start_time
+          AND NOT EXISTS (
+              SELECT 1
+              FROM experience_gift_invite_grant_log gl
+              WHERE gl.position_id = p.id
+                AND gl.product_id  = egp.id
+          )
+          AND NOT EXISTS (
+              SELECT 1
+              FROM experience_gift_record egr
+              WHERE egr.inviter_id = m.id
+                AND egr.product_id = egp.id
+          )
+          AND (
+              egp.per_user_limit = 0
+              OR (
+                  SELECT COUNT(*)
+                  FROM experience_gift_record egr
+                  WHERE egr.member_id   = m.inviter_id
+                    AND egr.product_id  = egp.id
+              ) &lt; egp.per_user_limit
+          )
+    </select>
+
+</mapper>

+ 44 - 0
datacenter/datacenter-job/src/main/resources/mapper/FollowerCleanupMapper.xml

@@ -0,0 +1,44 @@
+<?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.datacenter.mapper.FollowerCleanupMapper">
+
+    <resultMap id="candidateMap" type="com.datacenter.job.vo.FollowerCleanupCandidateVo">
+        <result column="followId"    property="followId"/>
+        <result column="customerId"  property="customerId"/>
+        <result column="traderId"    property="traderId"/>
+    </resultMap>
+
+    <!--
+        查询需要被移除的跟单员:
+          - follow.status = 10(跟随中)
+          - 跟单员账户余额 < 交易员配置的最小跟单金额(min_follow_amount,余额取 follow_wallet.balance)
+          - 跟单员在该交易员下无未平仓/部分平仓仓位(position.status IN (10, 20))
+          - 交易员身份确认:customer.trader = 30
+    -->
+    <select id="findCandidates" resultMap="candidateMap">
+        SELECT
+            f.id          AS followId,
+            f.customer_id AS customerId,
+            f.trader_id   AS traderId
+        FROM follow f
+        INNER JOIN customer fc
+            ON fc.id = f.customer_id
+        INNER JOIN kucoin.follow_wallet fw
+            ON fw.id = fc.member_id
+        INNER JOIN customer tc
+            ON tc.id = f.trader_id
+            AND tc.trader = 20
+        WHERE f.status = 10
+          AND fw.balance &lt; tc.min_follow_amount
+          AND NOT EXISTS (
+              SELECT 1
+              FROM position p
+              WHERE p.customer_id = f.customer_id
+                AND p.trader_id   = f.trader_id
+                AND p.status      IN (10, 20)
+          )
+    </select>
+
+</mapper>

+ 103 - 0
datacenter/datacenter-job/src/main/resources/mapper/StatCopyTradePositionMapper.xml

@@ -0,0 +1,103 @@
+<?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.datacenter.mapper.StatCopyTradePositionMapper">
+
+    <!--
+        聚合带单者的活跃仓位(含普通/无损跟单分类)。
+        带单者持仓量与跟单者持仓量分开子查询聚合,避免 LEFT JOIN 扇出导致带单者字段被重复累加。
+
+        带单者识别:copytrade.customer.trader = 20
+        普通跟单:is_special IS NULL OR is_special != 1
+        无损跟单:is_special = 1
+        活跃仓位:status != 2(2 = DONE)
+    -->
+    <select id="computeSnapshot" resultType="com.datacenter.entity.stat.StatCopyTradePosition">
+        SELECT
+            #{statTime}                                AS stat_time,
+            tr.member_id                               AS trader_member_id,
+            COALESCE(tl.name, '')                      AS trader_level,
+            tr.symbol                                  AS symbol,
+            tr.direction                               AS direction,
+            tr.leverage_rate,
+            tr.money_type,
+            tr.share_number,
+            tr.trader_qty,
+            tr.trader_avg_open,
+            tr.trader_total_principal,
+            COALESCE(fn.normal_follow_count,    0)     AS normal_follow_count,
+            COALESCE(fn.normal_follow_qty,      0)     AS normal_follow_qty,
+            COALESCE(fn.normal_follow_avg_open, 0)     AS normal_follow_avg_open,
+            COALESCE(fs.special_follow_count,   0)     AS special_follow_count,
+            COALESCE(fs.special_follow_qty,     0)     AS special_follow_qty,
+            COALESCE(fs.special_follow_avg_open,0)     AS special_follow_avg_open
+        FROM (
+            <!-- 带单者自身持仓单独聚合,不受跟单者 JOIN 影响 -->
+            SELECT
+                tp.member_id,
+                cc.symbol,
+                tp.direction,
+                MAX(tp.leverage)     AS leverage_rate,
+                MAX(tp.money_type)   AS money_type,
+                MAX(tp.share_number) AS share_number,
+                SUM(tp.total_position - COALESCE(tp.closed_position, 0))         AS trader_qty,
+                SUM(tp.open_price * (tp.total_position - COALESCE(tp.closed_position, 0))) /
+                    NULLIF(SUM(tp.total_position - COALESCE(tp.closed_position, 0)), 0)  AS trader_avg_open,
+                COALESCE(SUM(tp.total_principal_amount), 0)                       AS trader_total_principal
+            FROM member_contract_position tp
+            JOIN contract_coin cc ON cc.id = tp.contract_id
+            WHERE tp.status != 2
+              AND tp.follow_position_id IS NULL
+              AND tp.member_id IN (SELECT member_id FROM copytrade.customer WHERE trader = 20)
+            GROUP BY tp.member_id, cc.symbol, tp.direction
+        ) tr
+        LEFT JOIN copytrade.customer cu     ON cu.member_id = tr.member_id
+        LEFT JOIN copytrade.trader_level tl ON tl.id = cu.trader_level_id
+        LEFT JOIN (
+            <!-- 普通跟单持仓聚合(按带单仓位反查带单者) -->
+            SELECT
+                leader.member_id AS trader_member_id,
+                cc2.symbol,
+                leader.direction,
+                COUNT(DISTINCT fp.id)                                                       AS normal_follow_count,
+                COALESCE(SUM(fp.total_position - COALESCE(fp.closed_position, 0)), 0)       AS normal_follow_qty,
+                CASE WHEN COALESCE(SUM(fp.total_position - COALESCE(fp.closed_position, 0)), 0) > 0
+                     THEN SUM(fp.open_price * (fp.total_position - COALESCE(fp.closed_position, 0))) /
+                          SUM(fp.total_position - COALESCE(fp.closed_position, 0))
+                     ELSE 0 END                                                             AS normal_follow_avg_open
+            FROM member_contract_position fp
+            JOIN member_contract_position leader ON leader.id = fp.follow_position_id
+            JOIN contract_coin cc2 ON cc2.id = leader.contract_id
+            WHERE fp.status    != 2
+              AND (fp.is_special IS NULL OR fp.is_special != 1)
+              AND leader.status != 2
+              AND leader.follow_position_id IS NULL
+              AND leader.member_id IN (SELECT member_id FROM copytrade.customer WHERE trader = 20)
+            GROUP BY leader.member_id, cc2.symbol, leader.direction
+        ) fn ON fn.trader_member_id = tr.member_id AND fn.symbol = tr.symbol AND fn.direction = tr.direction
+        LEFT JOIN (
+            <!-- 无损跟单持仓聚合 -->
+            SELECT
+                leader.member_id AS trader_member_id,
+                cc2.symbol,
+                leader.direction,
+                COUNT(DISTINCT fp.id)                                                        AS special_follow_count,
+                COALESCE(SUM(fp.total_position - COALESCE(fp.closed_position, 0)), 0)        AS special_follow_qty,
+                CASE WHEN COALESCE(SUM(fp.total_position - COALESCE(fp.closed_position, 0)), 0) > 0
+                     THEN SUM(fp.open_price * (fp.total_position - COALESCE(fp.closed_position, 0))) /
+                          SUM(fp.total_position - COALESCE(fp.closed_position, 0))
+                     ELSE 0 END                                                              AS special_follow_avg_open
+            FROM member_contract_position fp
+            JOIN member_contract_position leader ON leader.id = fp.follow_position_id
+            JOIN contract_coin cc2 ON cc2.id = leader.contract_id
+            WHERE fp.status    != 2
+              AND fp.is_special = 1
+              AND leader.status != 2
+              AND leader.follow_position_id IS NULL
+              AND leader.member_id IN (SELECT member_id FROM copytrade.customer WHERE trader = 20)
+            GROUP BY leader.member_id, cc2.symbol, leader.direction
+        ) fs ON fs.trader_member_id = tr.member_id AND fs.symbol = tr.symbol AND fs.direction = tr.direction
+    </select>
+
+</mapper>

+ 9 - 0
datacenter/datacenter-job/src/main/resources/sql/experience_gift_invite_grant_log.sql

@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS `experience_gift_invite_grant_log` (
+  `id`          bigint       NOT NULL AUTO_INCREMENT,
+  `position_id` bigint       NOT NULL COMMENT '触发发放的合约仓位ID',
+  `product_id`  bigint       NOT NULL COMMENT '体验金产品ID',
+  `inviter_id`  bigint       NOT NULL COMMENT '被发放体验金的邀请人ID',
+  `created_at`  datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_position_product` (`position_id`, `product_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体验金直邀发放去重日志';

+ 12 - 0
datacenter/datacenter-job/src/main/resources/sql/experience_gift_risk_alert.sql

@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS `experience_gift_risk_alert` (
+  `id`            bigint       NOT NULL AUTO_INCREMENT,
+  `member_id`     bigint       NOT NULL COMMENT '预警用户ID',
+  `receipt_count` int          NOT NULL COMMENT '4h内领取体验金次数',
+  `profit_amount` decimal(20,8) NOT NULL COMMENT '4h内体验金平仓收益(USDT)',
+  `status`        tinyint      NOT NULL DEFAULT 0 COMMENT '状态: 0=告警, 1=已取消',
+  `created_at`    datetime     NOT NULL COMMENT '预警时间',
+  `handled_at`    datetime     DEFAULT NULL COMMENT '处理时间',
+  `handled_by`    bigint       DEFAULT NULL COMMENT '处理管理员ID',
+  PRIMARY KEY (`id`),
+  KEY `idx_member_created` (`member_id`, `created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体验金防刷预警记录';

+ 20 - 0
datacenter/datacenter-job/src/main/resources/sql/stat_contract_pnl.sql

@@ -0,0 +1,20 @@
+CREATE TABLE IF NOT EXISTS `stat_contract_pnl` (
+    `id`                      bigint        NOT NULL AUTO_INCREMENT,
+    `stat_date`               date          NOT NULL                    COMMENT '统计日期',
+    `include_internal`        tinyint(1)    NOT NULL DEFAULT 0          COMMENT '是否含内部账户(0否1是)',
+    `self_hold_profit`        decimal(20,8) NOT NULL DEFAULT 0.00000000 COMMENT '自主-当前持仓盈利(USDT)',
+    `self_hold_loss`          decimal(20,8) NOT NULL DEFAULT 0.00000000 COMMENT '自主-当前持仓亏损(USDT)',
+    `self_today_close_profit` decimal(20,8) NOT NULL DEFAULT 0.00000000 COMMENT '自主-今日平仓盈利(USDT)',
+    `self_today_close_loss`   decimal(20,8) NOT NULL DEFAULT 0.00000000 COMMENT '自主-今日平仓亏损(USDT)',
+    `self_total_close_profit` decimal(20,8) NOT NULL DEFAULT 0.00000000 COMMENT '自主-平仓总盈利(USDT)',
+    `self_total_close_loss`   decimal(20,8) NOT NULL DEFAULT 0.00000000 COMMENT '自主-平仓总亏损(USDT)',
+    `copy_hold_profit`        decimal(20,8) NOT NULL DEFAULT 0.00000000 COMMENT '跟单-当前持仓盈利(USDT)',
+    `copy_hold_loss`          decimal(20,8) NOT NULL DEFAULT 0.00000000 COMMENT '跟单-当前持仓亏损(USDT)',
+    `copy_today_close_profit` decimal(20,8) NOT NULL DEFAULT 0.00000000 COMMENT '跟单-今日平仓盈利(USDT)',
+    `copy_today_close_loss`   decimal(20,8) NOT NULL DEFAULT 0.00000000 COMMENT '跟单-今日平仓亏损(USDT)',
+    `copy_total_close_profit` decimal(20,8) NOT NULL DEFAULT 0.00000000 COMMENT '跟单-平仓总盈利(USDT)',
+    `copy_total_close_loss`   decimal(20,8) NOT NULL DEFAULT 0.00000000 COMMENT '跟单-平仓总亏损(USDT)',
+    `updated_time`            datetime      NOT NULL                    COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_date_internal` (`stat_date`, `include_internal`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='合约盈亏统计表(自主+跟单)';

+ 241 - 0
datacenter/datacenter-job/src/main/resources/sql/stat_platform_daily_pnl_backfill_300.sql

@@ -0,0 +1,241 @@
+-- 补算「合约跟单盈亏统计表」最近 300 天数据(包含今天)
+-- 对应管理后台:合约跟单盈亏统计表 / 补算历史数据
+-- 对应后端口径:StatPlatformDailyPnlServiceImpl.computeAndSave(date, includeInternal)
+--
+-- 性能说明:
+--   1. 不按天循环扫描大表,而是先生成 300 天日期维表,再对各业务表按日期一次性 GROUP BY。
+--   2. 会重算 stat_platform_daily_pnl 最近 300 天(含执行当天)、include_internal=0/1 两套数据。
+--   3. 目标日期范围内先 DELETE 后 INSERT,避免依赖唯一索引。
+
+DROP PROCEDURE IF EXISTS backfill_stat_platform_daily_pnl_300;
+
+DELIMITER //
+
+CREATE PROCEDURE backfill_stat_platform_daily_pnl_300()
+BEGIN
+    DECLARE v_i INT DEFAULT 299;
+    DECLARE v_stat_date DATE;
+    DECLARE v_range_start DATETIME;
+    DECLARE v_range_end DATETIME;
+    DECLARE v_range_start_ms BIGINT;
+    DECLARE v_range_end_ms BIGINT;
+
+    SET v_range_start = CAST(DATE_SUB(CURDATE(), INTERVAL 299 DAY) AS DATETIME);
+    SET v_range_end = DATE_ADD(CAST(CURDATE() AS DATETIME), INTERVAL 1 DAY);
+    SET v_range_start_ms = UNIX_TIMESTAMP(v_range_start) * 1000;
+    SET v_range_end_ms = UNIX_TIMESTAMP(v_range_end) * 1000;
+
+    DROP TEMPORARY TABLE IF EXISTS tmp_stat_dates;
+    CREATE TEMPORARY TABLE tmp_stat_dates (
+        stat_date DATE NOT NULL PRIMARY KEY
+    ) ENGINE=MEMORY;
+
+    WHILE v_i >= 0 DO
+        SET v_stat_date = DATE_SUB(CURDATE(), INTERVAL v_i DAY);
+        INSERT INTO tmp_stat_dates (stat_date) VALUES (v_stat_date);
+        SET v_i = v_i - 1;
+    END WHILE;
+
+    DELETE p
+    FROM stat_platform_daily_pnl p
+    JOIN tmp_stat_dates d ON d.stat_date = p.stat_date
+    WHERE p.include_internal IN (0, 1);
+
+    INSERT INTO stat_platform_daily_pnl (
+        stat_date,
+        include_internal,
+        deposit_amount,
+        withdraw_amount,
+        self_open_count,
+        self_open_amount,
+        self_close_profit,
+        self_close_loss,
+        self_close_diff,
+        self_fee,
+        self_funding_fee,
+        copy_open_count,
+        copy_open_amount,
+        copy_close_profit,
+        copy_close_loss,
+        copy_close_diff,
+        copy_fee,
+        copy_funding_fee,
+        updated_time
+    )
+    SELECT
+        d.stat_date,
+        ii.include_internal,
+        COALESCE(dep.deposit_amount, 0) AS deposit_amount,
+        COALESCE(wd.withdraw_amount, 0) AS withdraw_amount,
+        COALESCE(ord.self_open_count, 0) AS self_open_count,
+        COALESCE(ord.self_open_amount, 0) AS self_open_amount,
+        COALESCE(ord.self_close_profit, 0) AS self_close_profit,
+        COALESCE(ord.self_close_loss, 0) AS self_close_loss,
+        COALESCE(ord.self_close_loss, 0) - COALESCE(ord.self_close_profit, 0) AS self_close_diff,
+        CASE
+            WHEN COALESCE(tx.gross_self_fee, 0) + COALESCE(tx.gross_copy_fee, 0) = 0 THEN 0
+            ELSE ROUND(
+                COALESCE(tx.gross_self_fee, 0)
+                - (COALESCE(rw.total_rebates, 0) * COALESCE(tx.gross_self_fee, 0)
+                   / (COALESCE(tx.gross_self_fee, 0) + COALESCE(tx.gross_copy_fee, 0))),
+                8
+            )
+        END AS self_fee,
+        COALESCE(tx.self_funding_fee, 0) AS self_funding_fee,
+        COALESCE(ord.copy_open_count, 0) AS copy_open_count,
+        COALESCE(ord.copy_open_amount, 0) AS copy_open_amount,
+        COALESCE(ord.copy_close_profit, 0) AS copy_close_profit,
+        COALESCE(ord.copy_close_loss, 0) AS copy_close_loss,
+        COALESCE(ord.copy_close_loss, 0) - COALESCE(ord.copy_close_profit, 0) AS copy_close_diff,
+        CASE
+            WHEN COALESCE(tx.gross_self_fee, 0) + COALESCE(tx.gross_copy_fee, 0) = 0 THEN 0
+            ELSE ROUND(
+                COALESCE(tx.gross_copy_fee, 0)
+                - (COALESCE(rw.total_rebates, 0)
+                   - (COALESCE(rw.total_rebates, 0) * COALESCE(tx.gross_self_fee, 0)
+                      / (COALESCE(tx.gross_self_fee, 0) + COALESCE(tx.gross_copy_fee, 0)))),
+                8
+            )
+        END AS copy_fee,
+        COALESCE(tx.copy_funding_fee, 0) AS copy_funding_fee,
+        NOW() AS updated_time
+    FROM tmp_stat_dates d
+    CROSS JOIN (
+        SELECT 0 AS include_internal
+        UNION ALL
+        SELECT 1 AS include_internal
+    ) ii
+    LEFT JOIN (
+        SELECT
+            DATE(md.create_time) AS stat_date,
+            ii.include_internal,
+            COALESCE(SUM(md.amount), 0) AS deposit_amount
+        FROM member_deposit md
+        CROSS JOIN (
+            SELECT 0 AS include_internal
+            UNION ALL
+            SELECT 1 AS include_internal
+        ) ii
+        LEFT JOIN member m ON m.id = md.member_id
+        WHERE md.create_time >= v_range_start
+          AND md.create_time < v_range_end
+          AND md.unit LIKE '%USDT%'
+          AND (
+              ii.include_internal = 1
+              OR (md.member_id IS NOT NULL AND (m.id IS NULL OR COALESCE(m.is_internal, 0) <> 1))
+          )
+        GROUP BY DATE(md.create_time), ii.include_internal
+    ) dep ON dep.stat_date = d.stat_date AND dep.include_internal = ii.include_internal
+    LEFT JOIN (
+        SELECT
+            DATE(wr.deal_time) AS stat_date,
+            ii.include_internal,
+            COALESCE(SUM(wr.total_amount), 0) AS withdraw_amount
+        FROM withdraw_record wr
+        CROSS JOIN (
+            SELECT 0 AS include_internal
+            UNION ALL
+            SELECT 1 AS include_internal
+        ) ii
+        LEFT JOIN member m ON m.id = wr.member_id
+        WHERE wr.status = 3
+          AND wr.deal_time >= v_range_start
+          AND wr.deal_time < v_range_end
+          AND wr.coin_id LIKE '%USDT%'
+          AND (
+              ii.include_internal = 1
+              OR (wr.member_id IS NOT NULL AND (m.id IS NULL OR COALESCE(m.is_internal, 0) <> 1))
+          )
+        GROUP BY DATE(wr.deal_time), ii.include_internal
+    ) wd ON wd.stat_date = d.stat_date AND wd.include_internal = ii.include_internal
+    LEFT JOIN (
+        SELECT
+            DATE(FROM_UNIXTIME(coe.deal_time / 1000)) AS stat_date,
+            ii.include_internal,
+            SUM(CASE WHEN coe.entrust_type = 0 AND coe.follow_id IS NULL THEN 1 ELSE 0 END) AS self_open_count,
+            COALESCE(SUM(CASE WHEN coe.entrust_type = 0 AND coe.follow_id IS NULL
+                              THEN coe.traded_volume * coe.traded_price ELSE 0 END), 0) AS self_open_amount,
+            COALESCE(SUM(CASE WHEN coe.entrust_type = 1 AND coe.follow_id IS NULL AND coe.profit_and_loss > 0
+                              THEN coe.profit_and_loss ELSE 0 END), 0) AS self_close_profit,
+            COALESCE(SUM(CASE WHEN coe.entrust_type = 1 AND coe.follow_id IS NULL AND coe.profit_and_loss < 0
+                              THEN -coe.profit_and_loss ELSE 0 END), 0) AS self_close_loss,
+            SUM(CASE WHEN coe.entrust_type = 0 AND coe.follow_id IS NOT NULL THEN 1 ELSE 0 END) AS copy_open_count,
+            COALESCE(SUM(CASE WHEN coe.entrust_type = 0 AND coe.follow_id IS NOT NULL
+                              THEN coe.traded_volume * coe.traded_price ELSE 0 END), 0) AS copy_open_amount,
+            COALESCE(SUM(CASE WHEN coe.entrust_type = 1 AND coe.follow_id IS NOT NULL AND coe.profit_and_loss > 0
+                              THEN coe.profit_and_loss ELSE 0 END), 0) AS copy_close_profit,
+            COALESCE(SUM(CASE WHEN coe.entrust_type = 1 AND coe.follow_id IS NOT NULL AND coe.profit_and_loss < 0
+                              THEN -coe.profit_and_loss ELSE 0 END), 0) AS copy_close_loss
+        FROM contract_order_entrust coe
+        CROSS JOIN (
+            SELECT 0 AS include_internal
+            UNION ALL
+            SELECT 1 AS include_internal
+        ) ii
+        LEFT JOIN member m ON m.id = coe.member_id
+        WHERE coe.status = 3
+          AND coe.experience_record_id IS NULL
+          AND coe.entrust_type IN (0, 1)
+          AND coe.deal_time >= v_range_start_ms
+          AND coe.deal_time < v_range_end_ms
+          AND (
+              ii.include_internal = 1
+              OR (coe.member_id IS NOT NULL AND (m.id IS NULL OR COALESCE(m.is_internal, 0) <> 1))
+          )
+        GROUP BY DATE(FROM_UNIXTIME(coe.deal_time / 1000)), ii.include_internal
+    ) ord ON ord.stat_date = d.stat_date AND ord.include_internal = ii.include_internal
+    LEFT JOIN (
+        SELECT
+            DATE(mt.create_time) AS stat_date,
+            ii.include_internal,
+            COALESCE(SUM(CASE WHEN mt.type = 19 THEN -mt.amount ELSE 0 END), 0) AS gross_self_fee,
+            COALESCE(SUM(CASE WHEN mt.type = 37 THEN -mt.amount ELSE 0 END), 0) AS gross_copy_fee,
+            COALESCE(SUM(CASE WHEN mt.type = 26 THEN -mt.amount ELSE 0 END), 0) AS self_funding_fee,
+            COALESCE(SUM(CASE WHEN mt.type = 42 THEN -mt.amount ELSE 0 END), 0) AS copy_funding_fee
+        FROM member_transaction mt
+        CROSS JOIN (
+            SELECT 0 AS include_internal
+            UNION ALL
+            SELECT 1 AS include_internal
+        ) ii
+        LEFT JOIN member m ON m.id = mt.member_id
+        WHERE mt.type IN (19, 37, 26, 42)
+          AND mt.create_time >= v_range_start
+          AND mt.create_time < v_range_end
+          AND (
+              ii.include_internal = 1
+              OR (mt.member_id IS NOT NULL AND (m.id IS NULL OR COALESCE(m.is_internal, 0) <> 1))
+          )
+        GROUP BY DATE(mt.create_time), ii.include_internal
+    ) tx ON tx.stat_date = d.stat_date AND tx.include_internal = ii.include_internal
+    LEFT JOIN (
+        SELECT
+            DATE(crr.create_time) AS stat_date,
+            ii.include_internal,
+            COALESCE(SUM(crr.num), 0) AS total_rebates
+        FROM contract_reward_record crr
+        CROSS JOIN (
+            SELECT 0 AS include_internal
+            UNION ALL
+            SELECT 1 AS include_internal
+        ) ii
+        LEFT JOIN member m ON m.id = crr.from_member_id
+        WHERE crr.type IN (0, 1, 2)
+          AND crr.create_time >= v_range_start
+          AND crr.create_time < v_range_end
+          AND (
+              ii.include_internal = 1
+              OR (crr.from_member_id IS NOT NULL AND (m.id IS NULL OR COALESCE(m.is_internal, 0) <> 1))
+          )
+        GROUP BY DATE(crr.create_time), ii.include_internal
+    ) rw ON rw.stat_date = d.stat_date AND rw.include_internal = ii.include_internal
+    ORDER BY d.stat_date, ii.include_internal;
+
+    DROP TEMPORARY TABLE IF EXISTS tmp_stat_dates;
+END//
+
+DELIMITER ;
+
+CALL backfill_stat_platform_daily_pnl_300();
+
+DROP PROCEDURE IF EXISTS backfill_stat_platform_daily_pnl_300;

+ 18 - 0
datacenter/datacenter-job/src/main/resources/sql/stat_today_overview.sql

@@ -0,0 +1,18 @@
+-- 今日概览统计表(建表到 kucoin 库)
+-- datacenter 每小时定时任务刷新,admin 管理后台查询展示
+-- include_internal=0 不含内部账户;include_internal=1 含内部账户(member.is_internal=1)
+
+CREATE TABLE IF NOT EXISTS `stat_today_overview` (
+    `id`               bigint        NOT NULL AUTO_INCREMENT,
+    `stat_date`        date          NOT NULL                        COMMENT '统计日期',
+    `include_internal` tinyint(1)    NOT NULL DEFAULT 0              COMMENT '是否包含内部账户(0否 1是)',
+    `profit_loss`      decimal(20,8) NOT NULL DEFAULT 0.00000000     COMMENT '今日盈亏(USDT)',
+    `deposit_amount`   decimal(20,8) NOT NULL DEFAULT 0.00000000     COMMENT '今日充值(USDT)',
+    `withdraw_amount`  decimal(20,8) NOT NULL DEFAULT 0.00000000     COMMENT '今日提现(USDT)',
+    `trade_volume`     decimal(20,8) NOT NULL DEFAULT 0.00000000     COMMENT '今日交易额(USDT):合约+跟单开仓金额',
+    `register_count`   int           NOT NULL DEFAULT 0              COMMENT '今日注册用户数',
+    `login_count`      int           NOT NULL DEFAULT 0              COMMENT '今日登录用户数(按账号去重)',
+    `updated_time`     datetime      NOT NULL                        COMMENT '统计更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_stat_date_internal` (`stat_date`, `include_internal`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='今日概览统计表';

+ 99 - 0
datacenter/pom.xml

@@ -0,0 +1,99 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>2.7.18</version>
+        <relativePath/>
+    </parent>
+
+    <groupId>com.datacenter</groupId>
+    <artifactId>datacenter</artifactId>
+    <version>1.0-SNAPSHOT</version>
+    <packaging>pom</packaging>
+
+    <description>datacenter 数据统计服务</description>
+
+    <modules>
+        <module>datacenter-entity</module>
+        <module>datacenter-job</module>
+    </modules>
+
+    <properties>
+        <java.version>17</java.version>
+        <maven.compiler.source>17</maven.compiler.source>
+        <maven.compiler.target>17</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <spring-cloud.version>2021.0.8</spring-cloud.version>
+        <spring-cloud-alibaba.version>2021.0.5.0</spring-cloud-alibaba.version>
+        <common.version>1.0.0-SNAPSHOT</common.version>
+        <lombok.version>1.18.30</lombok.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.springframework.cloud</groupId>
+                <artifactId>spring-cloud-dependencies</artifactId>
+                <version>${spring-cloud.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>com.alibaba.cloud</groupId>
+                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
+                <version>${spring-cloud-alibaba.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <dependency>
+                <groupId>com.exchange</groupId>
+                <artifactId>common-core</artifactId>
+                <version>${common.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.exchange</groupId>
+                <artifactId>common-log</artifactId>
+                <version>${common.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.exchange</groupId>
+                <artifactId>common-redis</artifactId>
+                <version>${common.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.exchange</groupId>
+                <artifactId>common-mybatisPlus</artifactId>
+                <version>${common.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.exchange</groupId>
+                <artifactId>common-utils</artifactId>
+                <version>${common.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.baomidou</groupId>
+                <artifactId>dynamic-datasource-spring-boot-starter</artifactId>
+                <version>3.6.1</version>
+            </dependency>
+            <dependency>
+                <groupId>com.datacenter</groupId>
+                <artifactId>datacenter-entity</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <version>${lombok.version}</version>
+            <scope>provided</scope>
+        </dependency>
+    </dependencies>
+</project>