diff --git a/docs/清用户数据操作文档.md b/docs/清用户数据操作文档.md new file mode 100644 index 0000000..ed47f05 --- /dev/null +++ b/docs/清用户数据操作文档.md @@ -0,0 +1,283 @@ +# 清库操作文档:清空全部用户数据(含 IM) + +> 适用环境:荷塘月色业务后端 `friend_back` + IM 系统 `chat-im` +> 目标:清除**所有会员用户**相关数据(资料、会话、钱包/分销用户侧数据、IM 好友与聊天等) +> **不是**「注销单个用户」;也不是清空系统配置/管理员账号。 + +--- + +## 1. 先搞清楚有两套系统 + +| 系统 | 代码目录 | MySQL 库 | Redis | 文件 | +|------|----------|----------|-------|------| +| 业务 API | `friend_back` | **`chat_v1`** | **db 7** | `infra_file` + 对应对象存储(七牛/MinIO 等) | +| IM | `chat-im`(`im-platform` / `im-server`) | **`im_platform`** | **db 10** | MinIO bucket **`im-platform`** | + +- 会员 ID 对齐:`member_user.id` == `im_user.id` +- `friend_back` 只通过 HTTP 调 IM(`chat.base-url`),**没有「删除用户」的第三方 API**,IM 必须在 `im_platform` 库直接清 +- 特殊 IM 账号(**不要删**): + - `-100` 系统消息通知号 + - `-200` 客服号 + +生产配置参考: + +- 业务:`friend_back/yudao-server/src/main/resources/application-prod.yaml`(`chat_v1`、Redis db7、`chat.base-url`) +- IM:`chat-im/im-platform/src/main/resources/application-prod.yml`(`im_platform`、Redis db10) + +--- + +## 2. 操作前检查清单 + +1. **确认环境**(测试 / 预发 / 生产),禁止在未备份的生产库直接执行。 +2. **全量备份** + ```bash + mysqldump -h... -u... -p chat_v1 > chat_v1_backup_$(date +%Y%m%d).sql + mysqldump -h... -u... -p im_platform > im_platform_backup_$(date +%Y%m%d).sql + # Redis:按运维规范导出 RDB/AOF(业务 db7、IM db10) + # 对象存储:按需快照或确认可丢弃用户上传文件 + ``` +3. **停写流量**(强烈建议) + - 停或摘流:`yudao-server`、`im-platform`、`im-server` + - 避免删一半又有新注册/新消息写入 +4. **合规确认** + - 支付订单、退款、提现流水是否允许物理删除(默认建议**保留或脱敏归档**,见第 5 节) +5. **现有后台没有「一键清会员」接口**,只能运维 SQL + Redis + 存储清理。 + +--- + +## 3. 建议操作顺序(总览) + +```text +① 停服务 +② 备份 MySQL / Redis / 文件 +③ 清业务库 chat_v1(用户相关表) +④ 清 IM 库 im_platform(保留 -100/-200) +⑤ 清 Redis db7、db10 +⑥ 清用户上传文件(可选) +⑦ 启服务并验收 +``` + +--- + +## 4. 业务库 `chat_v1`:清用户数据 + +> 执行前先 `USE chat_v1;` +> 下面按「先子表、后主表」;若表不存在可跳过。 +> 全量重置测试环境可用 `TRUNCATE`;生产更建议 `DELETE` + 条件(如按 `tenant_id`)。 + +### 4.1 会员扩展与行为(必清) + +```sql +-- 相册 / 位置 / 动态相关 +TRUNCATE TABLE member_photo; +TRUNCATE TABLE member_location; +TRUNCATE TABLE member_recent; +TRUNCATE TABLE member_operation; +TRUNCATE TABLE member_view_record; +TRUNCATE TABLE member_recent_tag; +TRUNCATE TABLE member_recent_cfg; + +-- 认证 / 反馈 / 开通记录 / 积分经验签到 +TRUNCATE TABLE member_com_auth; +TRUNCATE TABLE member_feedback; +TRUNCATE TABLE member_open_type_user; +TRUNCATE TABLE member_point_record; +TRUNCATE TABLE member_level_record; +TRUNCATE TABLE member_experience_record; +TRUNCATE TABLE member_sign_in_record; +TRUNCATE TABLE member_address; +``` + +### 4.2 登录态 / 社交绑定(会员侧) + +```sql +-- user_type = 1 表示会员(Member) +DELETE FROM system_oauth2_access_token WHERE user_type = 1; +DELETE FROM system_oauth2_refresh_token WHERE user_type = 1; +DELETE FROM system_oauth2_approve WHERE user_type = 1; +DELETE FROM system_oauth2_code WHERE user_type = 1; + +DELETE FROM system_social_user_bind WHERE user_type = 1; +-- 可选:清理已无绑定的 social_user 孤儿数据(按你们库实际字段调整) + +DELETE FROM system_login_log WHERE user_type = 1; +DELETE FROM system_notify_message WHERE user_type = 1; +-- 短信验证码可按需清空 +TRUNCATE TABLE system_sms_code; +``` + +### 4.3 分销用户数据(必清用户侧) + +```sql +TRUNCATE TABLE trade_brokerage_record; +TRUNCATE TABLE trade_brokerage_withdraw; +TRUNCATE TABLE trade_brokerage_code; +TRUNCATE TABLE trade_brokerage_user; +``` + +### 4.4 钱包用户数据(必清用户侧) + +```sql +TRUNCATE TABLE pay_wallet_transaction; +TRUNCATE TABLE pay_wallet_recharge; +TRUNCATE TABLE pay_wallet_user_transition; -- 若存在 +TRUNCATE TABLE pay_wallet_tx_auth; -- 若存在 +DELETE FROM pay_wallet WHERE user_type = 1; +``` + +### 4.5 商城用户行为(若启用 mall) + +```sql +TRUNCATE TABLE trade_cart; +TRUNCATE TABLE trade_order_log; +TRUNCATE TABLE trade_order_item; +TRUNCATE TABLE trade_after_sale_log; +TRUNCATE TABLE trade_after_sale; +TRUNCATE TABLE trade_order; + +TRUNCATE TABLE product_browse_history; +TRUNCATE TABLE product_favorite; +TRUNCATE TABLE product_comment; +-- 优惠券领取记录等带 user_id 的 promotion_* 表按实际启用情况清理 +``` + +### 4.6 最后删会员主表 + +```sql +TRUNCATE TABLE member_user; +-- 或:DELETE FROM member_user; +``` + +### 4.7 业务库「不要清」的配置表(示例) + +保留系统能继续跑的配置: + +- `member_config`、`member_level`、`member_tag`、`member_group` +- `member_sign_in_config`、`member_open_type`(会员套餐) +- `pay_app`、`pay_channel`、`pay_wallet_recharge_package` +- `trade_config` +- `system_oauth2_client`、`system_social_client`、短信模板/渠道 +- `system_users` 等**后台管理员**账号(除非连运营账号一起重置) + +--- + +## 5. 支付流水是否删除(单独决策) + +| 策略 | 处理 | +|------|------| +| **推荐(测试可删、生产慎删)** | 保留 `pay_order` / `pay_order_extension` / `pay_refund` / `pay_transfer` / `pay_notify_*`,仅清用户侧钱包与会员 | +| **测试环境彻底重置** | 可一并 `TRUNCATE` 上述支付流水表,并确认与渠道对账无冲突 | + +若生产必须销毁支付痕迹,需法务/财务确认后另出工单,不建议与「清用户」混为一谈。 + +--- + +## 6. IM 库 `im_platform`:清 IM 数据 + +> 执行前先 `USE im_platform;` +> 建表参考:`chat-im/db/im-platform.sql` + +```sql +-- 消息与关系 +TRUNCATE TABLE im_private_message; +TRUNCATE TABLE im_group_message; +TRUNCATE TABLE im_friend; +TRUNCATE TABLE im_group_member; +TRUNCATE TABLE im_group; +TRUNCATE TABLE im_file_info; + +-- 用户:保留系统号 +DELETE FROM im_user WHERE id NOT IN (-100, -200); + +-- 敏感词库一般保留 +-- TRUNCATE TABLE im_sensitive_word; -- 仅当要重置词库时 +``` + +**说明:** + +- 不要依赖 `friend_back` 的 ChatApi 删用户(接口不支持) +- 清完后会员重新登录会走同步逻辑(`syncChatUser`)在 IM 侧重建用户 + +--- + +## 7. Redis + +**务必指定 DB,禁止误 FLUSH 其它库。** + +```bash +# 业务 Redis(application-prod: database 7) +redis-cli -n 7 FLUSHDB + +# IM Redis(im-platform/im-server: database 10) +redis-cli -n 10 FLUSHDB +``` + +若不能整库清空,至少清理: + +- 业务:`oauth2_access_token:*`、会员相关 lock/cache、`social_auth_state:*` 等 +- IM:`im:user:*`、`im:cache:*`、`im:message:*`、`im:readed:*` 等 + +--- + +## 8. 文件存储(可选) + +| 来源 | 处理建议 | +|------|----------| +| 业务头像/相册/认证图 | 根据 `infra_file` + `member_user.avatar` / `member_photo` 对应对象存储清理;测试环境可按配置整桶策略清理 | +| IM 图片视频 | MinIO bucket `im-platform`;清 `im_file_info` 后按 path 删对象,或测试环境整桶清理 | + +生产环境删除对象存储前务必确认无合规留存要求。 + +--- + +## 9. 启动与验收 + +1. 启动 `im-platform` → `im-server` → `yudao-server` +2. 检查: + ```sql + SELECT COUNT(*) FROM chat_v1.member_user; -- 期望 0 + SELECT COUNT(*) FROM im_platform.im_user + WHERE id NOT IN (-100, -200); -- 期望 0 + SELECT id, user_name FROM im_platform.im_user; -- 应还能看到 -100/-200 + ``` +3. App 侧: + - 旧 token 应失效,需重新注册/登录 + - 登录后 IM 可正常建链、发消息 + - 后台管理账号仍可登录(若未清 `system_users`) +4. 配置仍在:会员套餐、支付渠道、分销开关、敏感词等 + +--- + +## 10. 回滚 + +仅能依赖操作前备份: + +```bash +mysql -h... -u... -p chat_v1 < chat_v1_backup_YYYYMMDD.sql +mysql -h... -u... -p im_platform < im_platform_backup_YYYYMMDD.sql +# 恢复 Redis / 文件快照后重启服务 +``` + +--- + +## 11. 常见问题 + +**Q:只清 `member_user` 行不行?** +不行。登录 token、IM 消息、钱包、分销、相册等都会残留,且可能脏数据关联异常。 + +**Q:清完业务库,IM 会不会自动空?** +不会。必须单独清 `im_platform`(和 Redis db10)。 + +**Q:有没有管理后台按钮?** +没有会员「注销/全量删除」接口;管理员删除只作用于后台 `system_users`。 + +**Q:测试环境最快怎么干?** +停服务 → 备份 → 按第 4~7 节 TRUNCATE/DELETE + `FLUSHDB` → 启服务。 + +--- + +## 12. 变更记录 + +| 日期 | 说明 | +|------|------| +| 2026-08-12 | 初版:基于当前 `friend_back` + `chat-im` 配置与表结构整理 | diff --git a/pom.xml b/pom.xml index 6dadf37..286410b 100644 --- a/pom.xml +++ b/pom.xml @@ -16,17 +16,8 @@ yudao-module-system yudao-module-infra yudao-module-member - - - yudao-module-pay yudao-module-mall - - - - - - ${project.artifactId} diff --git a/script/crypto/resp_enc_private.pem b/script/crypto/resp_enc_private.pem new file mode 100644 index 0000000..aa56920 --- /dev/null +++ b/script/crypto/resp_enc_private.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC1K3mUuyaWg6eE +Ud2Hw0lrUvvpT/JBZf5LZU/LJeCnv/1M1xs6XKBDSIPT5Jd3zbzCtWR4ejrsQu1y +wsX/tJ15DWbKS/ESzAqEAbtIKNqhyr1LAydKt7uIeeu2c0pFEfxKjN2zruxEk94/ +Pyjxb9mvpcp4S5K29ncTqfYcPtSDjnmojIUwMmdjue1vcARacZ2FewX02qLbbK6o +mstA4ykFa7+lMWGkI+vYQJ0x26Ubj3Un6ydH9Rglm7tGjrk7gVvd3gRUL0QcofZx +AMsCRVZx4WWJi+KGu61i8GSD1rKC3kVpBmJSt+J7SJXLkW9kuQ7ee/5+b2U9Mo3J +p09vbqQPAgMBAAECggEAHQndYH4I5touWRKoH/EozPeNoi1qxpm7A0m3Wvf0xN6M +kqrt1dJv66jtXGLI9m53IXxioL7yv9jQZ3MZum0wSm1f+u/mMZ2b1+HRK3VE6ebt +pV7ZhuvhNrRYGDPthRmnZymiBYF1pF5RIrEUdnJ3oWRBCjwOxZ3Go1O8O3XEqVtB +UKPpBgoUQqzFzyeck3HKNCZH62eIdTUQnyp7cU6wyj/9NwOerF7RbRRRFuayPzdP +uHJVcye7/qX/4z8GlNLklP1QOXBg4Qj8x48doR8OaFwSrhoDjcjVQ+mdmoTPGcgL +DtZSnTiWtY3Z8czovKIFLdtF6Rg0L8P+2ovBnlwMDQKBgQDaFxlnfVXu30SJJSwX +6FgIdkRg/VjyuqAVo7T30qmRWENiyhfJ0MhMiiy57tkT0MwQOk3M/+Cp0ah4RTJS +OanEr6kttTd3FzjVlbjNmJML6PjLw0UCGxBbdKLVqiqiGfz86MTDn5c+NluIMJ8o +ZqQT6algUuDbJ9K5hfsNk8bnuwKBgQDUqW/byjSl7Ph+SHOfji2THHvLRdKHQL+W +ijbOc7FiIsZuQZ5YyqIq7Rbed7dq38Nb9zo9hk4vd5FgcFvI/hPZUy3TngPYJfwv +O+mvAdZGBCBY5V5waywhD+BRGrT7FVJ89iCQANPzCx4ficsKNiC7QTzUiCM3jbMD +1aZIUDo9vQKBgQCiAtjnbU9iFVAHhaq80+hZ2OmxF2N1Zbg4exWbqE45X9UnVTPY +w5WYbmkNcual9s0xHO7g5BDUdqBtQBJCzP1V1JycDOv4NzMMT54qdTZ/eYEhdZ0V +FF/RYNL6YOAs7Ztnkc9h93NWTvEuOdhyHdWzyTf/C3Jou6uqEGLQ0j/HswKBgHC2 +SRoVxue2fFH6NoX3hkI1U4t+O0ICWEkCgl/Tw80+nysqzjFDXMYhmQBaNTWI2XMx +yoV3XJMd7UhO5ObXe0+xHVXOpzz1jP6/43AlKhs77qUPFInC08XGM17pJ8j4GRsw +HDwWpD9pwhJ3Js0HuTtmLUZ7zSaRjLLYluQ22VeFAoGBAKz9Yd7/FfiaJ3RCG585 +E4WJg+BoO7uBw1xuTjKabt6gxTD3TcSAmnQEZrKSlmzxoF7ypdOUymJU46TzN5KD +1MBkLADN+mr2Xk1TtawORFZk0KfZsXuVDwtSMXrmq5nj1Ul8noq8Azz8qTxxcuW4 +pdry0NF+Wfjny8ATyFDKY08w +-----END PRIVATE KEY----- diff --git a/script/crypto/resp_enc_private_b64.txt b/script/crypto/resp_enc_private_b64.txt new file mode 100644 index 0000000..5969090 --- /dev/null +++ b/script/crypto/resp_enc_private_b64.txt @@ -0,0 +1 @@ +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC1K3mUuyaWg6eEUd2Hw0lrUvvpT/JBZf5LZU/LJeCnv/1M1xs6XKBDSIPT5Jd3zbzCtWR4ejrsQu1ywsX/tJ15DWbKS/ESzAqEAbtIKNqhyr1LAydKt7uIeeu2c0pFEfxKjN2zruxEk94/Pyjxb9mvpcp4S5K29ncTqfYcPtSDjnmojIUwMmdjue1vcARacZ2FewX02qLbbK6omstA4ykFa7+lMWGkI+vYQJ0x26Ubj3Un6ydH9Rglm7tGjrk7gVvd3gRUL0QcofZxAMsCRVZx4WWJi+KGu61i8GSD1rKC3kVpBmJSt+J7SJXLkW9kuQ7ee/5+b2U9Mo3Jp09vbqQPAgMBAAECggEAHQndYH4I5touWRKoH/EozPeNoi1qxpm7A0m3Wvf0xN6Mkqrt1dJv66jtXGLI9m53IXxioL7yv9jQZ3MZum0wSm1f+u/mMZ2b1+HRK3VE6ebtpV7ZhuvhNrRYGDPthRmnZymiBYF1pF5RIrEUdnJ3oWRBCjwOxZ3Go1O8O3XEqVtBUKPpBgoUQqzFzyeck3HKNCZH62eIdTUQnyp7cU6wyj/9NwOerF7RbRRRFuayPzdPuHJVcye7/qX/4z8GlNLklP1QOXBg4Qj8x48doR8OaFwSrhoDjcjVQ+mdmoTPGcgLDtZSnTiWtY3Z8czovKIFLdtF6Rg0L8P+2ovBnlwMDQKBgQDaFxlnfVXu30SJJSwX6FgIdkRg/VjyuqAVo7T30qmRWENiyhfJ0MhMiiy57tkT0MwQOk3M/+Cp0ah4RTJSOanEr6kttTd3FzjVlbjNmJML6PjLw0UCGxBbdKLVqiqiGfz86MTDn5c+NluIMJ8oZqQT6algUuDbJ9K5hfsNk8bnuwKBgQDUqW/byjSl7Ph+SHOfji2THHvLRdKHQL+WijbOc7FiIsZuQZ5YyqIq7Rbed7dq38Nb9zo9hk4vd5FgcFvI/hPZUy3TngPYJfwvO+mvAdZGBCBY5V5waywhD+BRGrT7FVJ89iCQANPzCx4ficsKNiC7QTzUiCM3jbMD1aZIUDo9vQKBgQCiAtjnbU9iFVAHhaq80+hZ2OmxF2N1Zbg4exWbqE45X9UnVTPYw5WYbmkNcual9s0xHO7g5BDUdqBtQBJCzP1V1JycDOv4NzMMT54qdTZ/eYEhdZ0VFF/RYNL6YOAs7Ztnkc9h93NWTvEuOdhyHdWzyTf/C3Jou6uqEGLQ0j/HswKBgHC2SRoVxue2fFH6NoX3hkI1U4t+O0ICWEkCgl/Tw80+nysqzjFDXMYhmQBaNTWI2XMxyoV3XJMd7UhO5ObXe0+xHVXOpzz1jP6/43AlKhs77qUPFInC08XGM17pJ8j4GRswHDwWpD9pwhJ3Js0HuTtmLUZ7zSaRjLLYluQ22VeFAoGBAKz9Yd7/FfiaJ3RCG585E4WJg+BoO7uBw1xuTjKabt6gxTD3TcSAmnQEZrKSlmzxoF7ypdOUymJU46TzN5KD1MBkLADN+mr2Xk1TtawORFZk0KfZsXuVDwtSMXrmq5nj1Ul8noq8Azz8qTxxcuW4pdry0NF+Wfjny8ATyFDKY08w \ No newline at end of file diff --git a/script/crypto/resp_enc_public.pem b/script/crypto/resp_enc_public.pem new file mode 100644 index 0000000..60f725c --- /dev/null +++ b/script/crypto/resp_enc_public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtSt5lLsmloOnhFHdh8NJ +a1L76U/yQWX+S2VPyyXgp7/9TNcbOlygQ0iD0+SXd828wrVkeHo67ELtcsLF/7Sd +eQ1mykvxEswKhAG7SCjaocq9SwMnSre7iHnrtnNKRRH8Sozds67sRJPePz8o8W/Z +r6XKeEuStvZ3E6n2HD7Ug455qIyFMDJnY7ntb3AEWnGdhXsF9Nqi22yuqJrLQOMp +BWu/pTFhpCPr2ECdMdulG491J+snR/UYJZu7Ro65O4Fb3d4EVC9EHKH2cQDLAkVW +ceFliYvihrutYvBkg9aygt5FaQZiUrfie0iVy5FvZLkO3nv+fm9lPTKNyadPb26k +DwIDAQAB +-----END PUBLIC KEY----- diff --git a/script/crypto/resp_enc_public_b64.txt b/script/crypto/resp_enc_public_b64.txt new file mode 100644 index 0000000..45e0106 --- /dev/null +++ b/script/crypto/resp_enc_public_b64.txt @@ -0,0 +1 @@ +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtSt5lLsmloOnhFHdh8NJa1L76U/yQWX+S2VPyyXgp7/9TNcbOlygQ0iD0+SXd828wrVkeHo67ELtcsLF/7SdeQ1mykvxEswKhAG7SCjaocq9SwMnSre7iHnrtnNKRRH8Sozds67sRJPePz8o8W/Zr6XKeEuStvZ3E6n2HD7Ug455qIyFMDJnY7ntb3AEWnGdhXsF9Nqi22yuqJrLQOMpBWu/pTFhpCPr2ECdMdulG491J+snR/UYJZu7Ro65O4Fb3d4EVC9EHKH2cQDLAkVWceFliYvihrutYvBkg9aygt5FaQZiUrfie0iVy5FvZLkO3nv+fm9lPTKNyadPb26kDwIDAQAB \ No newline at end of file diff --git a/sql/mysql/member/member_airline_account.sql b/sql/mysql/member/member_airline_account.sql new file mode 100644 index 0000000..25f92d4 --- /dev/null +++ b/sql/mysql/member/member_airline_account.sql @@ -0,0 +1,64 @@ +-- 航空账号(管理员开通:手机号+密码+有效期+设备唯一绑定) +CREATE TABLE IF NOT EXISTS `member_airline_account` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', + `mobile` varchar(11) NOT NULL COMMENT '手机号(同一手机号可在不同渠道各有一个账号)', + `channel` varchar(8) NOT NULL DEFAULT 'CZ' COMMENT '品牌渠道(CZ南航/MU东航/HU海航)', + `password` varchar(100) NOT NULL DEFAULT '' COMMENT '密码(BCrypt)', + `start_time` datetime NOT NULL COMMENT '生效开始时间', + `end_time` datetime NOT NULL COMMENT '生效结束时间', + `device_id` varchar(128) NULL DEFAULT NULL COMMENT '手机唯一识别号(首次登录绑定;重装/重启应由客户端保持不变)', + `status` tinyint NOT NULL DEFAULT 0 COMMENT '状态(0开启 1关闭)', + `remark` varchar(500) NULL DEFAULT NULL COMMENT '备注', + `user_id` bigint NULL DEFAULT NULL COMMENT '关联会员用户编号 member_user.id(已废弃,登录不再创建会员用户)', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', + `creator` varchar(64) NULL DEFAULT '' COMMENT '创建者', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` varchar(64) NULL DEFAULT '' COMMENT '更新者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_mobile_channel` (`mobile`, `channel`, `deleted`, `tenant_id`) USING BTREE, + UNIQUE KEY `uk_channel_device` (`channel`, `device_id`, `deleted`, `tenant_id`) USING BTREE, + KEY `idx_end_time` (`end_time`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='航空账号'; + +-- 菜单:航空账号管理(挂在会员中心 2262 下) +-- 若 id 冲突,请在管理后台「菜单管理」手动新建,或调整 id +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) +VALUES (5800, '航空账号', '', 2, 15, 2262, 'airline-account', 'ep:iphone', 'member/airlineAccount/index', 'MemberAirlineAccount', 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0') +ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `component` = VALUES(`component`); + +INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) +VALUES +(5801, '航空账号查询', 'member:airline-account:query', 3, 1, 5800, '', '', '', NULL, 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'), +(5802, '航空账号创建', 'member:airline-account:create', 3, 2, 5800, '', '', '', NULL, 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'), +(5803, '航空账号更新', 'member:airline-account:update', 3, 3, 5800, '', '', '', NULL, 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'), +(5804, '航空账号删除', 'member:airline-account:delete', 3, 4, 5800, '', '', '', NULL, 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0'), +(5805, '航空账号解绑设备', 'member:airline-account:unbind', 3, 5, 5800, '', '', '', NULL, 0, b'1', b'1', b'1', '1', NOW(), '1', NOW(), b'0') +ON DUPLICATE KEY UPDATE `permission` = VALUES(`permission`); + +-- 测试账号(App 登录) +-- 手机号:13800138000 密码:123456 有效期约一年 未绑定设备 +-- 密码哈希为 BCrypt(123456) +INSERT INTO `member_airline_account` +(`mobile`, `channel`, `password`, `start_time`, `end_time`, `device_id`, `status`, `remark`, `user_id`, `tenant_id`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) +SELECT '13800138000', + 'CZ', + '$2a$10$PplOV8yXcQo9xc32V8vwTuIMbMKgNtjXf3lu6l18LUTOy9YGDdAY6', + NOW() - INTERVAL 1 DAY, + NOW() + INTERVAL 365 DAY, + NULL, + 0, + '本地联调测试账号', + NULL, + 1, + '1', + NOW(), + '1', + NOW(), + b'0' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `member_airline_account` + WHERE `mobile` = '13800138000' AND `channel` = 'CZ' AND `deleted` = b'0' AND `tenant_id` = 1 +); diff --git a/sql/mysql/member/member_airline_account_add_channel.sql b/sql/mysql/member/member_airline_account_add_channel.sql new file mode 100644 index 0000000..215570e --- /dev/null +++ b/sql/mysql/member/member_airline_account_add_channel.sql @@ -0,0 +1,20 @@ +-- 航空账号增加品牌渠道(channel):CZ 南航 / MU 东航 / HU 海航 +-- 目的:三个 App 使用同一手机号分别登录,互不干扰。 +-- 唯一键调整: +-- uk_mobile (mobile, deleted, tenant_id) -> 废弃 +-- uk_device_id (device_id, deleted, tenant_id) -> 废弃 +-- uk_mobile_channel (mobile, channel, deleted, tenant_id) -> 同一手机号每渠道一个账号 +-- uk_channel_device (channel, device_id, deleted, tenant_id) -> 同一设备每渠道绑定一个账号 + +ALTER TABLE `member_airline_account` + ADD COLUMN `channel` varchar(8) NOT NULL DEFAULT 'CZ' COMMENT '品牌渠道(CZ南航/MU东航/HU海航)' AFTER `mobile`; + +-- 存量数据归属渠道默认回填为 CZ(南航),如实际属于东航/海航请手工执行: +-- UPDATE `member_airline_account` SET `channel` = 'MU' WHERE ...; +-- UPDATE `member_airline_account` SET `channel` = 'HU' WHERE ...; + +ALTER TABLE `member_airline_account` + DROP INDEX `uk_mobile`, + DROP INDEX `uk_device_id`, + ADD UNIQUE KEY `uk_mobile_channel` (`mobile`, `channel`, `deleted`, `tenant_id`), + ADD UNIQUE KEY `uk_channel_device` (`channel`, `device_id`, `deleted`, `tenant_id`); diff --git a/sql/mysql/member/member_airline_data.sql b/sql/mysql/member/member_airline_data.sql new file mode 100644 index 0000000..c1a12a8 --- /dev/null +++ b/sql/mysql/member/member_airline_data.sql @@ -0,0 +1,64 @@ +-- ---------------------------------------------------------------------------- +-- 航空 App 数据云同步:新增航线 / 我的(积点钱包) +-- 数据归属:member_airline_account.id(登录账号),一账号一套数据 +-- ---------------------------------------------------------------------------- + +-- 用户自建的飞行任务(航线) +CREATE TABLE IF NOT EXISTS `member_airline_flight` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', + `account_id` bigint NOT NULL COMMENT '航空账号编号 member_airline_account.id', + `flight_no` varchar(32) NULL DEFAULT '' COMMENT '航班号', + `title` varchar(64) NULL DEFAULT '' COMMENT '任务/航班名称', + `begin_city` varchar(64) NULL DEFAULT '' COMMENT '出发城市', + `begin_airport` varchar(128) NULL DEFAULT '' COMMENT '出发机场', + `end_city` varchar(64) NULL DEFAULT '' COMMENT '到达城市', + `end_airport` varchar(128) NULL DEFAULT '' COMMENT '到达机场', + `classes` varchar(128) NULL DEFAULT '' COMMENT '舱位/班次', + `status` varchar(32) NULL DEFAULT '' COMMENT '状态', + `fly_begin_date0` varchar(32) NULL DEFAULT '' COMMENT '计划起飞时间', + `fly_end_date0` varchar(32) NULL DEFAULT '' COMMENT '计划到达时间', + `fly_begin_date1` varchar(32) NULL DEFAULT '' COMMENT '实际起飞时间', + `fly_end_date1` varchar(32) NULL DEFAULT '' COMMENT '实际到达时间', + `sign_in_time` varchar(32) NULL DEFAULT '' COMMENT '签到时间', + `aircraft_no` varchar(32) NULL DEFAULT '' COMMENT '机号/编号', + `airline` varchar(64) NULL DEFAULT '' COMMENT '航司', + `aircraft_type` varchar(64) NULL DEFAULT '' COMMENT '机型', + `gate` varchar(32) NULL DEFAULT '' COMMENT '登机口', + `begin_stand` varchar(32) NULL DEFAULT '' COMMENT '出发机场停机位', + `end_stand` varchar(32) NULL DEFAULT '' COMMENT '到达机场停机位', + `cargo_close` varchar(32) NULL DEFAULT '' COMMENT '货关时间', + `pax_close` varchar(32) NULL DEFAULT '' COMMENT '客关时间', + `leave_port` varchar(32) NULL DEFAULT '' COMMENT '离港时间', + `take_off` varchar(32) NULL DEFAULT '' COMMENT '起飞时间', + `land` varchar(32) NULL DEFAULT '' COMMENT '落地时间', + `arrive_port` varchar(32) NULL DEFAULT '' COMMENT '到港时间', + `prev_flight` varchar(255) NULL DEFAULT '' COMMENT '前序航班', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', + `creator` varchar(64) NULL DEFAULT '' COMMENT '创建者', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` varchar(64) NULL DEFAULT '' COMMENT '更新者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_account_id` (`account_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='航空App用户自建航线'; + +-- 我的页面:积点/钱包(每账号一行) +CREATE TABLE IF NOT EXISTS `member_airline_point` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', + `account_id` bigint NOT NULL COMMENT '航空账号编号 member_airline_account.id', + `current_month` varchar(16) NULL DEFAULT '00' COMMENT '当月飞行', + `current_year` varchar(16) NULL DEFAULT '00' COMMENT '年度飞行', + `jixiao` varchar(16) NULL DEFAULT '00' COMMENT '绩效积点', + `yanwu` varchar(16) NULL DEFAULT '00' COMMENT '延误积点', + `current_point` varchar(16) NULL DEFAULT '00' COMMENT '当前积点', + `wallet` varchar(16) NULL DEFAULT '00' COMMENT '钱包余额', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', + `creator` varchar(64) NULL DEFAULT '' COMMENT '创建者', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` varchar(64) NULL DEFAULT '' COMMENT '更新者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_account_id` (`account_id`, `deleted`, `tenant_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='航空App我的积点钱包'; diff --git a/sql/mysql/member/member_airline_flight_add_detail_columns.sql b/sql/mysql/member/member_airline_flight_add_detail_columns.sql new file mode 100644 index 0000000..3306b3d --- /dev/null +++ b/sql/mysql/member/member_airline_flight_add_detail_columns.sql @@ -0,0 +1,21 @@ +-- ---------------------------------------------------------------------------- +-- member_airline_flight 补齐航线详情字段(登机口/货关/客关/停机位/离港/起飞/落地/到港等) +-- 背景:早期云同步表只建了 15 个基础字段,客户端上报的详情字段被服务端丢弃, +-- 拉取后详情页登机口、货关、客关等为空。本脚本为存量库补列。 +-- 已执行过本脚本的库无需重复执行(列已存在会报 Duplicate column)。 +-- ---------------------------------------------------------------------------- + +ALTER TABLE `member_airline_flight` + ADD COLUMN `aircraft_no` varchar(32) NULL DEFAULT '' COMMENT '机号/编号' AFTER `sign_in_time`, + ADD COLUMN `airline` varchar(64) NULL DEFAULT '' COMMENT '航司' AFTER `aircraft_no`, + ADD COLUMN `aircraft_type` varchar(64) NULL DEFAULT '' COMMENT '机型' AFTER `airline`, + ADD COLUMN `gate` varchar(32) NULL DEFAULT '' COMMENT '登机口' AFTER `aircraft_type`, + ADD COLUMN `begin_stand` varchar(32) NULL DEFAULT '' COMMENT '出发机场停机位' AFTER `gate`, + ADD COLUMN `end_stand` varchar(32) NULL DEFAULT '' COMMENT '到达机场停机位' AFTER `begin_stand`, + ADD COLUMN `cargo_close` varchar(32) NULL DEFAULT '' COMMENT '货关时间' AFTER `end_stand`, + ADD COLUMN `pax_close` varchar(32) NULL DEFAULT '' COMMENT '客关时间' AFTER `cargo_close`, + ADD COLUMN `leave_port` varchar(32) NULL DEFAULT '' COMMENT '离港时间' AFTER `pax_close`, + ADD COLUMN `take_off` varchar(32) NULL DEFAULT '' COMMENT '起飞时间' AFTER `leave_port`, + ADD COLUMN `land` varchar(32) NULL DEFAULT '' COMMENT '落地时间' AFTER `take_off`, + ADD COLUMN `arrive_port` varchar(32) NULL DEFAULT '' COMMENT '到港时间' AFTER `land`, + ADD COLUMN `prev_flight` varchar(255) NULL DEFAULT '' COMMENT '前序航班' AFTER `arrive_port`; diff --git a/sql/mysql/member/member_interaction.sql b/sql/mysql/member/member_interaction.sql new file mode 100644 index 0000000..7699028 --- /dev/null +++ b/sql/mysql/member/member_interaction.sql @@ -0,0 +1,18 @@ +-- 会员互动消息(被查看 / 微信被解锁) +CREATE TABLE IF NOT EXISTS `member_interaction` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', + `receiver_id` bigint NOT NULL COMMENT '接收人(被查看 / 被解锁用户)', + `actor_id` bigint NOT NULL COMMENT '操作人', + `biz_type` varchar(32) NOT NULL COMMENT 'VIEWED / WECHAT_UNLOCKED', + `ref_id` bigint NULL DEFAULT NULL COMMENT '关联业务 id(访客记录 / 操作记录)', + `read_status` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否已读', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', + `creator` varchar(64) NULL DEFAULT '' COMMENT '创建者', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` varchar(64) NULL DEFAULT '' COMMENT '更新者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_receiver_read` (`receiver_id`, `read_status`) USING BTREE, + KEY `idx_receiver_time` (`receiver_id`, `id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会员互动消息'; diff --git a/sql/mysql/member/member_push_device.sql b/sql/mysql/member/member_push_device.sql new file mode 100644 index 0000000..2a181d4 --- /dev/null +++ b/sql/mysql/member/member_push_device.sql @@ -0,0 +1,18 @@ +-- 会员推送设备(极光 RegistrationID 绑定) +CREATE TABLE IF NOT EXISTS `member_push_device` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', + `member_id` bigint NOT NULL COMMENT '会员编号', + `registration_id` varchar(64) NOT NULL COMMENT '极光 RegistrationID', + `alias` varchar(64) NULL DEFAULT NULL COMMENT '极光别名,如 u{memberId}', + `platform` varchar(16) NOT NULL COMMENT 'ios / android', + `provider` varchar(16) NOT NULL DEFAULT 'jpush' COMMENT '推送服务商', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', + `creator` varchar(64) NULL DEFAULT '' COMMENT '创建者', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` varchar(64) NULL DEFAULT '' COMMENT '更新者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_registration_id` (`registration_id`) USING BTREE, + KEY `idx_member_id` (`member_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会员推送设备'; diff --git a/sql/mysql/member/member_ride_record_mock.py b/sql/mysql/member/member_ride_record_mock.py new file mode 100644 index 0000000..9a1b3fd --- /dev/null +++ b/sql/mysql/member/member_ride_record_mock.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""骑遇骑行记录:建表 + 模拟数据(chat_v1 库)。可重复执行(幂等:表已存在跳过,模拟数据按标题去重)。""" +import json +import math +import pymysql + +DB = dict(host='139.224.25.207', port=3306, user='chat_v1', + password='BpJ2Ebt4zMPjBcCL', database='chat_v1', charset='utf8mb4', + connect_timeout=15) + +DDL = [ + """ + CREATE TABLE IF NOT EXISTS `member_ride_record` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '编号', + `user_id` bigint(20) NOT NULL COMMENT '骑手编号 member_user.id', + `title` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '战报标题', + `road_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '路线名(如:南昆山)', + `corner_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '弯道名(如:观音潭右弯),空表示非跑山记录', + `distance_km` decimal(8,1) NOT NULL COMMENT '里程(公里)', + `duration_sec` int(11) NOT NULL COMMENT '骑行时长(秒)', + `top_speed_kmh` decimal(5,1) NOT NULL COMMENT '极速(km/h)', + `curve_count` int(11) NOT NULL DEFAULT '0' COMMENT '过弯次数', + `max_lean` decimal(4,1) NOT NULL COMMENT '最大压弯角度(度)', + `track_json` mediumtext COLLATE utf8mb4_unicode_ci COMMENT '轨迹点 JSON [{lat,lng,speed}],仅详情接口返回', + `like_count` int(11) NOT NULL DEFAULT '0' COMMENT '点赞数(冗余计数)', + `report_count` int(11) NOT NULL DEFAULT '0' COMMENT '被举报次数', + `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0 正常 1 已下架', + `tenant_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '租户编号', + `creator` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + PRIMARY KEY (`id`), + KEY `idx_user` (`user_id`), + KEY `idx_rank` (`road_name`, `corner_name`, `max_lean`) + ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='骑遇骑行记录(战报)' + """, + """ + CREATE TABLE IF NOT EXISTS `member_ride_record_like` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '编号', + `record_id` bigint(20) NOT NULL COMMENT '战报编号 member_ride_record.id', + `user_id` bigint(20) NOT NULL COMMENT '点赞人编号 member_user.id', + `tenant_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '租户编号', + `creator` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_record_user` (`record_id`, `user_id`) + ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='骑遇骑行战报点赞' + """, +] + +# (user_id, title, road, corner, km, sec, top, curves, max_lean, days_ago, like_user_ids) +MOCK = [ + (131, '南昆山 63 连弯,今天左弯终于不虚了', '南昆山', '观音潭右弯', 87, 5760, 138, 63, 52.0, 1, [133, 132, 134]), + (133, '清晨跑山,观音潭右弯刷出个人最佳', '南昆山', '观音潭右弯', 84, 5400, 132, 58, 49.5, 2, [131, 129]), + (129, '压弯一时爽,一直压弯一直爽', '南昆山', '观音潭右弯', 86, 5900, 129, 61, 46.0, 4, [131]), + (132, '第一次跑南昆山,右弯还是不敢深压', '南昆山', '观音潭右弯', 88, 6300, 112, 57, 33.5, 5, [134]), + (131, '连续 S 弯练车,大腿已废', '南昆山', '连续 S 弯', 52, 3900, 118, 41, 44.5, 1, [133, 129]), + (133, 'S 弯 41 连过,人车合一', '南昆山', '连续 S 弯', 52, 3950, 112, 41, 40.0, 3, [131, 134, 132]), + (129, '大夫山夜骑放风,发卡弯有点意思', '大夫山', '发卡弯', 44, 3660, 105, 38, 43.0, 1, [131, 133]), + (131, '大夫山发卡弯,极速压到 43 度', '大夫山', '发卡弯', 43, 3500, 108, 35, 43.2, 3, [133]), + (134, '傍晚放风,夕阳配弯道,舒服', '大夫山', '发卡弯', 44, 3800, 98, 36, 29.0, 4, [132]), + (131, '罗浮山风门坳长弯,全程 6 档', '罗浮山', '风门坳长弯', 121, 7680, 121, 22, 39.0, 2, [133, 129]), + (133, '风门坳长弯磨膝体验,就一次', '罗浮山', '风门坳长弯', 118, 7500, 126, 20, 47.5, 6, [131]), + (129, '周末摩旅,156 公里赶到营地', '', '', 156, 7680, 121, 22, 25.0, 3, [131, 133, 132, 134]), + (133, '市区通勤顺路兜风,绿道休闲骑', '', '', 32, 3000, 85, 12, 18.5, 1, [131]), + (132, '新手第一份战报,求带飞', '', '', 26, 2700, 76, 9, 15.0, 2, [134]), +] + + +def track_points(seed: int) -> str: + """生成一个小环形轨迹 JSON(约 60 点),让战报详情有图可画。""" + r = 0.004 + (seed % 7) * 0.0004 + pts = [] + n = 60 + for i in range(n): + t = i / n * math.pi * 4 + radius = r + r * 0.35 * math.sin(t * 2.3 + seed) + lat = 23.17 + seed * 0.002 + radius * math.cos(t) + lng = 113.36 + seed * 0.003 + radius * 1.4 * math.sin(t) + speed = round(40 + 60 * (0.5 + 0.5 * math.sin(t * 1.7 + seed)), 1) + pts.append({'lat': round(lat, 6), 'lng': round(lng, 6), 'speed': speed}) + return json.dumps(pts, separators=(',', ':')) + + +def main(): + conn = pymysql.connect(**DB) + cur = conn.cursor() + + for ddl in DDL: + cur.execute(ddl) + conn.commit() + print('DDL ok') + + inserted = 0 + for idx, (uid, title, road, corner, km, sec, top, curves, lean, days_ago, likers) in enumerate(MOCK): + cur.execute("SELECT id FROM member_ride_record WHERE title=%s AND deleted=0", (title,)) + if cur.fetchone(): + print('skip (exists):', title) + continue + cur.execute( + """INSERT INTO member_ride_record + (user_id, title, road_name, corner_name, distance_km, duration_sec, + top_speed_kmh, curve_count, max_lean, track_json, like_count, report_count, + status, tenant_id, creator, create_time) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,0,1,%s, + DATE_SUB(NOW(), INTERVAL %s DAY) - INTERVAL %s HOUR)""", + (uid, title, road, corner, km, sec, top, curves, lean, + track_points(idx), len(likers), uid, days_ago, (idx * 3) % 20)) + record_id = cur.lastrowid + for liker in likers: + cur.execute( + """INSERT IGNORE INTO member_ride_record_like + (record_id, user_id, tenant_id, creator, create_time) + VALUES (%s,%s,1,%s, DATE_SUB(NOW(), INTERVAL 1 DAY))""", + (record_id, liker, uid)) + inserted += 1 + print('inserted:', title) + conn.commit() + + cur.execute("SELECT COUNT(*) FROM member_ride_record WHERE deleted=0") + print('total records:', cur.fetchone()[0]) + cur.execute("SELECT road_name, corner_name, COUNT(*) FROM member_ride_record " + "WHERE deleted=0 AND corner_name<>'' GROUP BY road_name, corner_name") + for r in cur.fetchall(): + print('board:', r) + conn.close() + print('done, inserted =', inserted) + + +if __name__ == '__main__': + main() diff --git a/sql/mysql/promotion/banner_position_dynamic.sql b/sql/mysql/promotion/banner_position_dynamic.sql new file mode 100644 index 0000000..0bed852 --- /dev/null +++ b/sql/mysql/promotion/banner_position_dynamic.sql @@ -0,0 +1,20 @@ +-- Banner 位置字典:补「动态页面」= 6(App 动态顶部轮播) +-- 若库中已有 value=6,执行会因业务唯一性报错时可忽略;或先查: +-- SELECT * FROM system_dict_data WHERE dict_type='promotion_banner_position' AND value='6'; + +INSERT INTO `system_dict_data` ( + `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, + `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted` +) +SELECT 0, '动态页面', '6', 'promotion_banner_position', 0, 'primary', '', + 'App 动态页 Banner', '1', NOW(), '1', NOW(), b'0' +FROM (SELECT 1 AS _) AS t +WHERE NOT EXISTS ( + SELECT 1 FROM `system_dict_data` + WHERE `dict_type` = 'promotion_banner_position' AND `value` = '6' AND `deleted` = b'0' +); + +-- 确保 Banner 菜单可见:营销中心 → 内容管理 → Banner +UPDATE `system_menu` +SET `status` = 0, `visible` = b'1', `deleted` = b'0', `update_time` = NOW() +WHERE `id` IN (2030, 2387, 2025); diff --git a/sql/mysql/system/system_menu_subaccount_gender.sql b/sql/mysql/system/system_menu_subaccount_gender.sql new file mode 100644 index 0000000..66d544d --- /dev/null +++ b/sql/mysql/system/system_menu_subaccount_gender.sql @@ -0,0 +1,17 @@ +-- 后台菜单与授权:子账号管理 + 会员管理拆分(男号/女号) +-- 执行库:chat_v1 +-- 1) 新增菜单(幂等:先删后插) +DELETE FROM system_menu WHERE id IN (5810, 5820, 5821); +INSERT INTO system_menu (id, name, permission, type, sort, parent_id, path, icon, component, component_name, status, visible, keep_alive, always_show, creator, updater, deleted) +VALUES +(5810, '子账号管理', 'system:sub-account:manage', 2, 20, 0, '/sub-account', 'ep:user', 'system/subaccount/index', 'SystemSubAccount', 0, b'1', b'1', b'1', '1', '1', b'0'), +(5820, '男号管理', 'member:user:query', 2, 1, 2262, 'male-user', 'ep:male', 'member/user/male', 'MemberUserMale', 0, b'1', b'1', b'1', '1', '1', b'0'), +(5821, '女号管理', 'member:user:query', 2, 2, 2262, 'female-user', 'ep:female', 'member/user/female', 'MemberUserFemale', 0, b'1', b'1', b'1', '1', '1', b'0'); + +-- 2) 授权给「APP管理员」角色(AppHolder 当前使用的角色) +DELETE FROM system_role_menu WHERE role_id = 158 AND menu_id IN (5810, 5820, 5821); +INSERT INTO system_role_menu (role_id, menu_id, creator, updater, deleted, tenant_id) +VALUES +(158, 5810, '1', '1', b'0', 1), +(158, 5820, '1', '1', b'0', 1), +(158, 5821, '1', '1', b'0', 1); diff --git a/sql/mysql/system/system_oauth2_client_airline.sql b/sql/mysql/system/system_oauth2_client_airline.sql new file mode 100644 index 0000000..4d7ef9a --- /dev/null +++ b/sql/mysql/system/system_oauth2_client_airline.sql @@ -0,0 +1,22 @@ +-- ---------------------------- +-- 航空账号专用 OAuth2 客户端 +-- 用途:airline-login 签发的 Token 归属 airline 客户端,access/refresh token 有效期极长, +-- App 端登录一次即可长期使用,无需续期;每次打开 App 仅通过 /airline-check +-- 校验账号状态/有效期/设备绑定(见 MemberAuthServiceImpl#createTokenAfterAirlineLoginSuccess)。 +-- 注意:不影响 default 客户端(交友 App 会员仍为 30 分钟 accessToken + 30 天 refreshToken)。 +-- 部署:执行本脚本后再部署新 yudao-server.jar,否则 airline-login 会报"客户端不存在"。 +-- ---------------------------- + +INSERT INTO `system_oauth2_client` +(`id`, `client_id`, `secret`, `name`, `logo`, `description`, `status`, + `access_token_validity_seconds`, `refresh_token_validity_seconds`, + `redirect_uris`, `authorized_grant_types`, `scopes`, `auto_approve_scopes`, + `authorities`, `resource_ids`, `additional_information`, + `creator`, `create_time`, `updater`, `update_time`, `deleted`) +SELECT 43, 'airline', 'airline123', '航空App专用客户端', '', '南航/东航/海航 App 专用,Token 长期有效', 0, + 2147483647, 2147483647, + '[]', '["password","refresh_token"]', '[]', '[]', + '[]', '[]', '{}', + '1', NOW(), '1', NOW(), b'0' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `system_oauth2_client` WHERE `client_id` = 'airline' AND `deleted` = b'0'); diff --git a/sql/mysql/system/system_sub_account.sql b/sql/mysql/system/system_sub_account.sql new file mode 100644 index 0000000..3925165 --- /dev/null +++ b/sql/mysql/system/system_sub_account.sql @@ -0,0 +1,17 @@ +-- 子账号管理:holder 与 子账号/专属角色 的绑定关系 +-- 执行位置:chat_v1 库 +CREATE TABLE IF NOT EXISTS `system_sub_account` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `holder_user_id` bigint NOT NULL COMMENT '管理者用户编号(system_users.id)', + `sub_user_id` bigint NOT NULL COMMENT '子账号用户编号(system_users.id)', + `role_id` bigint NOT NULL COMMENT '子账号专属角色编号(system_role.id)', + `creator` varchar(64) NULL DEFAULT '' COMMENT '创建者', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` varchar(64) NULL DEFAULT '' COMMENT '更新者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', + PRIMARY KEY (`id`), + KEY `idx_holder_user_id` (`holder_user_id`), + KEY `idx_sub_user_id` (`sub_user_id`) +) ENGINE = InnoDB COMMENT '后台子账号表'; diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java index 5bf5d6c..9e02aa0 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java @@ -1,27 +1,80 @@ package cn.iocoder.yudao.framework.common.util.json.databind; import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import java.io.IOException; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.format.DateTimeFormatter; /** * 基于时间戳的 LocalDateTime 反序列化器 * + * 支持两种 JSON 写法: + * 1. 时间戳:13 位毫秒(yudao 前端默认),兼容 10 位秒级时间戳 + * 2. 日期字符串:"yyyy-MM-dd HH:mm:ss"、"yyyy-MM-dd"、ISO-8601("yyyy-MM-dd'T'HH:mm:ss") + * + * 注意:原实现直接 {@code p.getValueAsLong()},传入 "yyyy-MM-dd HH:mm:ss" 字符串时 + * 会静默解析失败并返回 0,导致入库时间变成 1970-01-01 08:00:00(东八区 epoch 0)。 + * * @author 老五 */ public class TimestampLocalDateTimeDeserializer extends JsonDeserializer { public static final TimestampLocalDateTimeDeserializer INSTANCE = new TimestampLocalDateTimeDeserializer(); + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + @Override public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { - // 将 Long 时间戳,转换为 LocalDateTime 对象 - return LocalDateTime.ofInstant(Instant.ofEpochMilli(p.getValueAsLong()), ZoneId.systemDefault()); + // 1. 数字:直接按毫秒时间戳处理(与原行为一致) + if (p.getCurrentToken() == JsonToken.VALUE_NUMBER_INT) { + return LocalDateTime.ofInstant(Instant.ofEpochMilli(p.getLongValue()), ZoneId.systemDefault()); + } + + // 2. 字符串:先尝试时间戳,再尝试常见日期格式 + String text = p.getValueAsString(); + if (text == null) { + return null; + } + text = text.trim(); + if (text.isEmpty()) { + return null; + } + // 2.1 纯数字:13 位毫秒、10 位秒 + if (text.matches("\\d+")) { + long timestamp = Long.parseLong(text); + if (text.length() <= 10) { // 秒级时间戳,转毫秒 + timestamp *= 1000; + } + return LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()); + } + // 2.2 "yyyy-MM-dd HH:mm:ss" + try { + return LocalDateTime.parse(text, DATE_TIME_FORMATTER); + } catch (Exception ignore) { + // 继续尝试其它格式 + } + // 2.3 "yyyy-MM-dd"(按当天 00:00:00) + try { + return LocalDate.parse(text, DATE_FORMATTER).atStartOfDay(); + } catch (Exception ignore) { + // 继续尝试 ISO 格式 + } + // 2.4 ISO-8601:"yyyy-MM-dd'T'HH:mm:ss" + try { + return LocalDateTime.parse(text); + } catch (Exception ignore) { + // 落到最后的报错 + } + throw ctxt.weirdStringException(text, LocalDateTime.class, + "Expected timestamp (millis or seconds) or date string (yyyy-MM-dd HH:mm:ss)"); } } diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java index 6090d1d..508035d 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/handler/GlobalExceptionHandler.java @@ -387,6 +387,12 @@ public class GlobalExceptionHandler { return CommonResult.error(NOT_IMPLEMENTED.getCode(), "[支付模块 yudao-module-pay - 表结构未导入][参考 https://cloud.iocoder.cn/pay/build/ 开启]"); } + // 7.1 会员互动消息 + if (message.contains("member_interaction")) { + log.error("[会员互动消息表 member_interaction 未导入][请执行 friend_back/sql/mysql/member/member_interaction.sql]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "会员互动消息表未导入,请执行 sql/mysql/member/member_interaction.sql"); + } // 8. AI 大模型 if (message.contains("ai_")) { log.error("[AI 大模型 yudao-module-ai - 表结构未导入][参考 https://cloud.iocoder.cn/ai/build/ 开启]"); diff --git a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApi.java b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApi.java index 399e731..8b13c6b 100644 --- a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApi.java +++ b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApi.java @@ -30,6 +30,14 @@ public interface TradeBrokerageRecordApi { */ void addBrokerage(Long userId, BrokerageRecordBizTypeEnum bizType, @Valid List list); + /** + * 取消佣金:将佣金记录状态修改为已失效 + * + * @param bizType 业务类型 + * @param bizId 业务编号 + */ + void cancelBrokerage(BrokerageRecordBizTypeEnum bizType, String bizId); + /** * 微信转账 * @param id 提现单id diff --git a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/brokerage/BrokerageRecordBizTypeEnum.java b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/brokerage/BrokerageRecordBizTypeEnum.java index cf7fbc2..1a1b776 100644 --- a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/brokerage/BrokerageRecordBizTypeEnum.java +++ b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/brokerage/BrokerageRecordBizTypeEnum.java @@ -16,8 +16,9 @@ import java.util.Arrays; public enum BrokerageRecordBizTypeEnum implements ArrayValuable { ORDER(1, "获得推广佣金", "获得推广佣金 {}", true), - WITHDRAW(2, "提现申请", "提现申请扣除佣金 {}", false), - WITHDRAW_REJECT(3, "提现申请驳回", "提现申请驳回,返还佣金 {}", true), + OPEN_TYPE(2, "获得会员佣金", "获得会员佣金 {}", true), + WITHDRAW(3, "提现申请", "提现申请扣除佣金 {}", false), + WITHDRAW_REJECT(4, "提现申请驳回", "提现申请驳回,返还佣金 {}", true), ; public static final Integer[] ARRAYS = Arrays.stream(values()).map(BrokerageRecordBizTypeEnum::getType).toArray(Integer[]::new); diff --git a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/brokerage/BrokerageWithdrawTypeEnum.java b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/brokerage/BrokerageWithdrawTypeEnum.java index b374566..465f914 100644 --- a/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/brokerage/BrokerageWithdrawTypeEnum.java +++ b/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/enums/brokerage/BrokerageWithdrawTypeEnum.java @@ -18,8 +18,12 @@ public enum BrokerageWithdrawTypeEnum implements ArrayValuable { WALLET(1, "钱包"), BANK(2, "银行卡"), WECHAT(3, "微信"), // 手动打款 - ALIPAY(4, "支付宝"), - WECHAT_API(5, "微信零钱"), // 自动打款,通过微信转账 API + ALIPAY(4, "支付宝"), // 手动打款 + /** + * 历史命名 WECHAT_API;App 端佣金提现实际传 type=5 + 支付宝账号,运营后台手动打款。 + * 切勿再走微信转账 API。 + */ + WECHAT_API(5, "支付宝"), ; public static final Integer[] ARRAYS = Arrays.stream(values()).map(BrokerageWithdrawTypeEnum::getType).toArray(Integer[]::new); @@ -39,13 +43,11 @@ public enum BrokerageWithdrawTypeEnum implements ArrayValuable { } /** - * 是否通过支付平台的 API 打款 - * - * @param type 类型 - * @return 是否 + * 是否通过支付平台的 API 打款。 + * 当前业务均为运营手动打款,不再走微信/支付宝自动转账。 */ public static boolean isApi(Integer type) { - return WECHAT_API.getType().equals(type); + return false; } } diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApiImpl.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApiImpl.java index c620da8..2903038 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApiImpl.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/api/brokerage/TradeBrokerageRecordApiImpl.java @@ -63,6 +63,11 @@ public class TradeBrokerageRecordApiImpl implements TradeBrokerageRecordApi { brokerageRecordService.addBrokerage(userId, bizType, BeanUtils.toBean(list, BrokerageAddReqBO.class)); } + @Override + public void cancelBrokerage(BrokerageRecordBizTypeEnum bizType, String bizId) { + brokerageRecordService.cancelBrokerage(bizType, bizId); + } + /** * 微信转账 * diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/BrokerageWithdrawController.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/BrokerageWithdrawController.java index 66df7cf..fba271a 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/BrokerageWithdrawController.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/BrokerageWithdrawController.java @@ -1,10 +1,15 @@ package cn.iocoder.yudao.module.trade.controller.admin.brokerage; +import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog; import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageParam; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.number.MoneyUtils; +import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils; import cn.iocoder.yudao.module.member.api.user.MemberUserApi; import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; import cn.iocoder.yudao.module.pay.api.notify.dto.PayTransferNotifyReqDTO; +import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.withdraw.BrokerageWithdrawExcelVO; import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.withdraw.BrokerageWithdrawPageReqVO; import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.withdraw.BrokerageWithdrawRejectReqVO; import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.withdraw.BrokerageWithdrawRespVO; @@ -22,9 +27,15 @@ import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import javax.annotation.security.PermitAll; +import javax.servlet.http.HttpServletResponse; import javax.validation.Valid; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import java.util.Optional; +import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; import static cn.iocoder.yudao.framework.common.util.servlet.ServletUtils.getClientIP; @@ -81,6 +92,39 @@ public class BrokerageWithdrawController { return success(BrokerageWithdrawConvert.INSTANCE.convertPage(pageResult, userMap)); } + @GetMapping("/export-excel") + @Operation(summary = "导出佣金提现 Excel") + @PreAuthorize("@ss.hasPermission('trade:brokerage-withdraw:query')") + @ApiAccessLog(operateType = EXPORT) + public void exportBrokerageWithdrawExcel(@Valid BrokerageWithdrawPageReqVO pageVO, + HttpServletResponse response) throws IOException { + pageVO.setPageSize(PageParam.PAGE_SIZE_NONE); + PageResult pageResult = brokerageWithdrawService.getBrokerageWithdrawPage(pageVO); + Map userMap = memberUserApi.getUserMap( + convertSet(pageResult.getList(), BrokerageWithdrawDO::getUserId)); + + List excelList = new ArrayList<>(pageResult.getList().size()); + for (BrokerageWithdrawDO row : pageResult.getList()) { + BrokerageWithdrawExcelVO excel = new BrokerageWithdrawExcelVO(); + excel.setId(row.getId()); + excel.setUserId(row.getUserId()); + excel.setUserNickname(Optional.ofNullable(userMap.get(row.getUserId())) + .map(MemberUserRespDTO::getNickname).orElse(null)); + excel.setPriceYuan(row.getPrice() == null ? "0" : MoneyUtils.fenToYuanStr(row.getPrice())); + excel.setFeePriceYuan(row.getFeePrice() == null ? "0" : MoneyUtils.fenToYuanStr(row.getFeePrice())); + excel.setTypeName("支付宝"); + excel.setAccountNo(row.getAccountNo()); + excel.setName(row.getName()); + excel.setStatus(row.getStatus()); + excel.setAuditReason(row.getAuditReason()); + excel.setAuditTime(row.getAuditTime()); + excel.setCreateTime(row.getCreateTime()); + excel.setRemark(row.getRemark()); + excelList.add(excel); + } + ExcelUtils.write(response, "佣金提现.xls", "数据", BrokerageWithdrawExcelVO.class, excelList); + } + // TODO @luchi:update-transferred,url 改成这个。和 update-paid 、update-refunded 保持一致 @PostMapping("/update-transfer") @Operation(summary = "更新转账订单为转账成功") // 由 pay-module 支付服务,进行回调,可见 PayNotifyJob diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/withdraw/BrokerageWithdrawExcelVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/withdraw/BrokerageWithdrawExcelVO.java new file mode 100644 index 0000000..a6c3291 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/admin/brokerage/vo/withdraw/BrokerageWithdrawExcelVO.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.withdraw; + +import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; +import cn.iocoder.yudao.framework.excel.core.convert.DictConvert; +import cn.iocoder.yudao.module.trade.enums.DictTypeConstants; +import com.alibaba.excel.annotation.ExcelProperty; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 佣金提现 Excel VO + */ +@Data +public class BrokerageWithdrawExcelVO { + + @ExcelProperty("编号") + private Long id; + + @ExcelProperty("用户编号") + private Long userId; + + @ExcelProperty("用户昵称") + private String userNickname; + + @ExcelProperty("提现金额(元)") + private String priceYuan; + + @ExcelProperty("手续费(元)") + private String feePriceYuan; + + @ExcelProperty("提现方式") + private String typeName; + + @ExcelProperty("支付宝账号") + private String accountNo; + + @ExcelProperty("真实姓名") + private String name; + + @ExcelProperty(value = "状态", converter = DictConvert.class) + @DictFormat(DictTypeConstants.BROKERAGE_WITHDRAW_STATUS) + private Integer status; + + @ExcelProperty("审核原因") + private String auditReason; + + @ExcelProperty("审核时间") + private LocalDateTime auditTime; + + @ExcelProperty("申请时间") + private LocalDateTime createTime; + + @ExcelProperty("备注") + private String remark; + +} diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/withdraw/AppBrokerageWithdrawRespVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/withdraw/AppBrokerageWithdrawRespVO.java index 3d0b988..f225172 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/withdraw/AppBrokerageWithdrawRespVO.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/withdraw/AppBrokerageWithdrawRespVO.java @@ -30,6 +30,15 @@ public class AppBrokerageWithdrawRespVO { @Schema(description = "提现时间", requiredMode = Schema.RequiredMode.REQUIRED) private LocalDateTime createTime; + @Schema(description = "审核驳回原因", example = "信息不完整") + private String auditReason; + + @Schema(description = "转账失败原因", example = "用户未确认收款") + private String transferErrorMsg; + + @Schema(description = "备注", example = "账号信息有误") + private String remark; + // ========== 微信转账专属 ========== @Schema(description = "转账单编号", example = "1024") diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageCodeServiceImpl.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageCodeServiceImpl.java index 238bfc8..11a9322 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageCodeServiceImpl.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageCodeServiceImpl.java @@ -38,7 +38,8 @@ import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.BROKERAGE_C @Validated public class BrokerageCodeServiceImpl implements BrokerageCodeService { - private static final String POSTER_VERSION_MARKER = "#poster-v2-hd"; + /** v4:邀请链接改为 /invite.html?code=&userId=,二维码同步刷新 */ + private static final String POSTER_VERSION_MARKER = "#poster-v4-invite-html"; private static final String BROKERAGE_CODE_GENERATE_LOCK_KEY = "trade:brokerage-code:generate:"; @@ -144,17 +145,14 @@ public class BrokerageCodeServiceImpl implements BrokerageCodeService { if (ObjectUtil.isEmpty(currentCode) || isLatestPoster(currentCode.getPosterPic())) { return currentCode; } + // 海报图统一印分销码 code,与手动填推荐码查表字段一致 String posterCode = currentCode.getCode(); String userAvatar = ""; MemberUserRespDTO user = memberUserApi.getUser(userId); if (ObjectUtil.isNotEmpty(user)) { userAvatar = user.getAvatar(); - if (ObjectUtil.isNotEmpty(user.getUserCode())) { - posterCode = user.getUserCode(); - } } - String posterUrl = ObjectUtil.isNotEmpty(currentCode.getUrl()) - ? currentCode.getUrl() : generatePosterUrl(currentCode.getCode(), userId); + String posterUrl = generatePosterUrl(currentCode.getCode(), userId); String posterPic = generatePosterPic(userAvatar, posterCode, posterUrl); if (ObjectUtil.isEmpty(posterPic)) { return currentCode; @@ -204,19 +202,15 @@ public class BrokerageCodeServiceImpl implements BrokerageCodeService { isRepet = ObjectUtil.isNotEmpty(brokerageCode); } - // 查询用户获取用户头像 + // 查询用户获取用户头像;海报图印分销码 code(与绑定查表一致) String userAvatar = ""; - String posterCode = code; MemberUserRespDTO user = memberUserApi.getUser(userId); if (ObjectUtil.isNotEmpty(user)) { userAvatar = user.getAvatar(); - if (ObjectUtil.isNotEmpty(user.getUserCode())) { - posterCode = user.getUserCode(); - } } String url = generatePosterUrl(code, userId); - BrokerageCodeDO saveObj = BrokerageCodeDO.builder().memberId(userId).code(code).posterPic(generatePosterPic(userAvatar, posterCode, url)).url(url).build(); + BrokerageCodeDO saveObj = BrokerageCodeDO.builder().memberId(userId).code(code).posterPic(generatePosterPic(userAvatar, code, url)).url(url).build(); brokerageCodeMapper.insert(saveObj); return saveObj; } @@ -237,7 +231,12 @@ public class BrokerageCodeServiceImpl implements BrokerageCodeService { } private String generatePosterUrl(String code, Long userId) { - return brokerageConfiguration.getDownloadUrl() + "?code=" + code + "&userId=" + userId; + String base = brokerageConfiguration.getDownloadUrl(); + if (base != null && base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + // 格式:http://download.htysapp.com/invite.html?code=569876&userId=90 + return base + "/invite.html?code=" + code + "&userId=" + userId; } } diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java index 19cd16e..213d951 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImpl.java @@ -79,14 +79,10 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService { public void addBrokerage(Long userId, BrokerageRecordBizTypeEnum bizType, List list) { TradeConfigDO memberConfig = tradeConfigService.getTradeConfig(); // 0 未启用分销功能 - if (memberConfig == null || !BooleanUtil.isTrue(memberConfig.getBrokerageEnabled())) { - log.error("[addBrokerage][增加佣金失败:brokerageEnabled 未配置,userId({}) bizType({}) list({})", userId, bizType, list); - return; - } - // 1.1 获得一级推广人 + // 1.1 获得一级推广人(人人分销:只要绑定了上级即可分佣,不校验上级推广员开关) BrokerageUserDO firstUser = brokerageUserService.getBindBrokerageUser(userId); - if (firstUser == null || !BooleanUtil.isTrue(firstUser.getBrokerageEnabled())) { + if (firstUser == null) { return; } // 1.2 计算一级分佣 @@ -98,7 +94,7 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService { return; } BrokerageUserDO secondUser = brokerageUserService.getBrokerageUser(firstUser.getBindUserId()); - if (secondUser == null || !BooleanUtil.isTrue(secondUser.getBrokerageEnabled())) { + if (secondUser == null) { return; } // 2.2 计算二级分佣 @@ -124,9 +120,9 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService { return; } - // 1.1 获得一级推广人 + // 1.1 获得一级推广人(人人分销:只要绑定了上级即可分佣,不校验上级推广员开关) BrokerageUserDO firstUser = brokerageUserService.getBindBrokerageUser(userId); - if (firstUser == null || !BooleanUtil.isTrue(firstUser.getBrokerageEnabled())) { + if (firstUser == null) { return; } // 1.1.1查看一级分销员是否已经分销过 @@ -145,7 +141,7 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService { return; } BrokerageUserDO secondUser = brokerageUserService.getBrokerageUser(firstUser.getBindUserId()); - if (secondUser == null || !BooleanUtil.isTrue(secondUser.getBrokerageEnabled())) { + if (secondUser == null) { return; } // 2.1.1查看二级分销员是否已经分销过 diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java index bf3b23c..a55ae55 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java @@ -189,14 +189,9 @@ public class BrokerageUserServiceImpl implements BrokerageUserService { } // 2.3 校验能否绑定 validateCanBindUser(brokerageUser, bindUserId); - // 2.3 绑定用户 + // 2.3 绑定用户(人人分销:新用户默认具备分销资格) if (isNewBrokerageUser) { - Integer enabledCondition = tradeConfigService.getTradeConfig().getBrokerageEnabledCondition(); - if (BrokerageEnabledConditionEnum.ALL.getCondition().equals(enabledCondition)) { // 人人分销:用户默认就有分销资格 - brokerageUser.setBrokerageEnabled(true).setBrokerageTime(LocalDateTime.now()); - } else { - brokerageUser.setBrokerageEnabled(false).setBrokerageTime(LocalDateTime.now()); - } + brokerageUser.setBrokerageEnabled(true).setBrokerageTime(LocalDateTime.now()); brokerageUserMapper.insert(fillBindUserData(bindUserId, brokerageUser)); } else { brokerageUserMapper.updateById(fillBindUserData(bindUserId, new BrokerageUserDO().setId(userId))); diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImpl.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImpl.java index 549fda1..c0589a4 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImpl.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImpl.java @@ -124,17 +124,14 @@ public class BrokerageWithdrawServiceImpl implements BrokerageWithdrawService { .setUserId(withdraw.getUserId()).setUserType(UserTypeEnum.MEMBER.getValue()) .setBizType(PayWalletBizTypeEnum.BROKERAGE_WITHDRAW.getType()).setBizId(withdraw.getId().toString()) .setPrice(withdraw.getPrice())); - // 1.2 微信 API - } else if (BrokerageWithdrawTypeEnum.WECHAT_API.getType().equals(withdraw.getType())) { - // TODO @luchi:这里,要加个转账单号的记录;另外,调用 API 转账,是立马成功,还是有延迟的哈? - createPayTransfer(withdraw); - // 1.3 剩余类型,都是手动打款,所以不处理 + // 1.2 历史 type=5 曾走微信转账 API;现业务为支付宝账号提现,统一手动打款 + // 若再走 createPayTransfer,无小程序 openid 会 NPE → 管理端「系统异常」 } else { - // TODO 可优化:未来可以考虑,接入支付宝、银联等 API 转账,实现自动打款 - log.info("[auditBrokerageWithdrawSuccess][withdraw({}) 类型({}) 手动打款,无需处理]", withdraw.getId(), withdraw.getType()); + log.info("[auditBrokerageWithdrawSuccess][withdraw({}) 类型({}) 手动打款,账号={}]", + withdraw.getId(), withdraw.getType(), withdraw.getAccountNo()); } - // 2. 非支付 API,则直接体现成功 + // 2. 非支付 API,则直接提现成功 if (!BrokerageWithdrawTypeEnum.isApi(withdraw.getType())) { brokerageWithdrawMapper.updateByIdAndStatus(withdraw.getId(), BrokerageWithdrawStatusEnum.AUDIT_SUCCESS.getStatus(), new BrokerageWithdrawDO().setStatus(BrokerageWithdrawStatusEnum.WITHDRAW_SUCCESS.getStatus())); diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/util/PosterPicGen.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/util/PosterPicGen.java index a49375d..f557702 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/util/PosterPicGen.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/util/PosterPicGen.java @@ -55,7 +55,7 @@ public class PosterPicGen { private int pageHeight = 700; - private String title = "荷塘月色"; + private String title = "荷塘助手"; private String description = "荷塘湖畔 相伴月色"; @@ -160,10 +160,8 @@ public class PosterPicGen { RenderingHints.VALUE_FRACTIONALMETRICS_ON); } - private void drawUserAvatar(BufferedImage avatar) { - if (avatar == null) { - return; - } + private void drawUserAvatar(BufferedImage avatar) throws IOException{ + BufferedImage logoAvater = ImageIO.read(ResourceUtil.getResource("image/logo.png")); graphics.setColor(Color.WHITE); graphics.fill(new RoundRectangle2D.Double( AVATAR_X - AVATAR_CLEAR_MARGIN, @@ -172,7 +170,7 @@ public class PosterPicGen { AVATAR_SIZE + AVATAR_CLEAR_MARGIN * 2.0, (AVATAR_CORNER_RADIUS + AVATAR_CLEAR_MARGIN) * 2.0, (AVATAR_CORNER_RADIUS + AVATAR_CLEAR_MARGIN) * 2.0)); - drawAvatar(avatar, AVATAR_X, AVATAR_Y, AVATAR_SIZE, + drawAvatar(logoAvater, AVATAR_X, AVATAR_Y, AVATAR_SIZE, new RoundRectangle2D.Double(0, 0, AVATAR_SIZE, AVATAR_SIZE, AVATAR_CORNER_RADIUS * 2.0, AVATAR_CORNER_RADIUS * 2.0)); } diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/resources/image/logo.png b/yudao-module-mall/yudao-module-trade-biz/src/main/resources/image/logo.png new file mode 100644 index 0000000..9e5fa28 Binary files /dev/null and b/yudao-module-mall/yudao-module-trade-biz/src/main/resources/image/logo.png differ diff --git a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java index 013d824..b0bee96 100644 --- a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java +++ b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/MemberErrorCode.java @@ -91,4 +91,24 @@ public interface MemberErrorCode { ErrorCode CHAT_USER_COUNT_EXCEED = new ErrorCode(1_004_018_000, "当天免费聊天人数已用完"); ErrorCode CHAT_MSG_COUNT_EXCEED = new ErrorCode(1_004_018_001, "当天免费聊天次数已用完"); ErrorCode CHAT_TOKEN_CREATE_FAILED = new ErrorCode(1_004_018_002, "聊天登录凭证签发失败,请稍后重试"); + + //========== 航空账号 1-004-019-000 ========== + ErrorCode AIRLINE_ACCOUNT_NOT_EXISTS = new ErrorCode(1_004_019_000, "航空账号不存在"); + ErrorCode AIRLINE_ACCOUNT_MOBILE_EXISTS = new ErrorCode(1_004_019_001, "该手机号已存在航空账号"); + ErrorCode AIRLINE_ACCOUNT_DISABLED = new ErrorCode(1_004_019_002, "航空账号已禁用"); + ErrorCode AIRLINE_ACCOUNT_NOT_STARTED = new ErrorCode(1_004_019_003, "航空账号尚未生效"); + ErrorCode AIRLINE_ACCOUNT_EXPIRED = new ErrorCode(1_004_019_004, "航空账号已到期,无法登录"); + ErrorCode AIRLINE_ACCOUNT_DEVICE_BOUND = new ErrorCode(1_004_019_005, "该账号已绑定其他手机,请联系管理员解绑"); + ErrorCode AIRLINE_ACCOUNT_DEVICE_USED = new ErrorCode(1_004_019_006, "该手机已绑定其他航空账号"); + ErrorCode AIRLINE_ACCOUNT_TIME_INVALID = new ErrorCode(1_004_019_007, "结束时间必须晚于开始时间"); + ErrorCode AIRLINE_ACCOUNT_BAD_CREDENTIALS = new ErrorCode(1_004_019_008, "航空账号手机号或密码不正确"); + + // ========== 骑遇发车局 / 骑行标签 1-004-020-000 ========== + ErrorCode RIDE_PLAN_NOT_EXISTS = new ErrorCode(1_004_020_000, "发车局不存在或已下架"); + ErrorCode RIDE_PLAN_MEET_TIME_INVALID = new ErrorCode(1_004_020_001, "集合时间必须晚于当前时间"); + ErrorCode RIDE_PLAN_FULL = new ErrorCode(1_004_020_002, "该发车局已满员"); + ErrorCode RIDE_PLAN_ALREADY_JOINED = new ErrorCode(1_004_020_003, "已加入该发车局,请勿重复加入"); + ErrorCode RIDE_PLAN_NOT_JOINED = new ErrorCode(1_004_020_004, "尚未加入该发车局"); + ErrorCode RIDE_TAG_NOT_EXISTS = new ErrorCode(1_004_020_005, "骑行标签不存在或已停用"); + ErrorCode RIDE_RECORD_NOT_EXISTS = new ErrorCode(1_004_020_006, "骑行战报不存在或已下架"); } diff --git a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/interaction/MemberInteractionBizTypeEnum.java b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/interaction/MemberInteractionBizTypeEnum.java new file mode 100644 index 0000000..372b95b --- /dev/null +++ b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/enums/interaction/MemberInteractionBizTypeEnum.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.member.enums.interaction; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 互动消息类型 + */ +@Getter +@AllArgsConstructor +public enum MemberInteractionBizTypeEnum { + + VIEWED("VIEWED", "查看了你"), + WECHAT_UNLOCKED("WECHAT_UNLOCKED", "解锁了你的微信"); + + private final String type; + private final String contentSuffix; + +} diff --git a/yudao-module-member/yudao-module-member-biz/pom.xml b/yudao-module-member/yudao-module-member-biz/pom.xml index af08c35..3a2f7a0 100644 --- a/yudao-module-member/yudao-module-member-biz/pom.xml +++ b/yudao-module-member/yudao-module-member-biz/pom.xml @@ -43,6 +43,11 @@ yudao-module-pay-api ${revision} + + cn.iocoder.boot + yudao-module-trade-api + ${revision} + cn.iocoder.boot @@ -101,6 +106,13 @@ yudao-spring-boot-starter-biz-ip + + + cn.jpush.api + jpush-client + 3.7.7 + + com.aliyun diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/constant/ChatConstant.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/constant/ChatConstant.java index 7eefa83..0220d60 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/constant/ChatConstant.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/constant/ChatConstant.java @@ -17,7 +17,7 @@ public final class ChatConstant { * 客服用户id */ public static final Long COM_SERVICE_USER_ID = -200L; - public static final String COM_SERVICE_USER_NAME = "荷塘月色客服"; + public static final String COM_SERVICE_USER_NAME = "荷塘助手客服"; public static final String COM_SERVICE_USER_AVATAR = "https://api.htysapp.com/admin-api/infra/file/4/get/affa4cac924cb13fe5706100828685015d512bca7dfa0e26ef82422b31877abc.png"; public static final List IGNORE_USER_IDS = Arrays.asList(0L, COM_MESSAGE_USER_ID, COM_SERVICE_USER_ID); diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java index bffa8c1..e5c2789 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApi.java @@ -29,6 +29,11 @@ public interface ChatApi { */ boolean createOrUpdate(@Valid ChatCreateOrUpdateUserReqVO dto); + /** + * 封禁聊天用户并推送下线 + */ + boolean banUser(Long userId, String reason); + /** * 根据userId查询用户详情 */ diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java index 32aadd8..7ed76e8 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ChatApiImpl.java @@ -121,6 +121,36 @@ public class ChatApiImpl implements ChatApi { } } + /** + * 封禁聊天用户并推送下线 + */ + @Override + public boolean banUser(Long userId, String reason) { + if (userId == null) { + return false; + } + ChatApiTool.API api = setApiConfig(ChatApiTool.USER_BAN); + try { + ChatBanUserReqVO dto = new ChatBanUserReqVO(userId, reason); + String body = JSONUtil.toJsonStr(dto); + String res = HttpRequest.post(api.getFullPath()).header(apiCountersign(body)).body(body).execute().body(); + log.debug("封禁聊天用户,res:{}", res); + if (ObjectUtil.isEmpty(res)) { + log.error("封禁聊天用户,res为空"); + return false; + } + ChatBaseRespVO baseRes = JSONUtil.toBean(res, ChatBaseRespVO.class); + if (ObjectUtil.isEmpty(baseRes) || !baseRes.isSuccess()) { + log.error("封禁聊天用户,返回错误,res:{}", res); + return false; + } + return true; + } catch (Exception e) { + log.error("封禁聊天用户,意外的错误,userId={}", userId, e); + return false; + } + } + /** * 根据userId查询用户详情 */ diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessor.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessor.java index 136b6a3..883096a 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessor.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessor.java @@ -10,6 +10,7 @@ import javax.imageio.stream.ImageOutputStream; import java.awt.Color; import java.awt.Graphics2D; import java.awt.RenderingHints; +import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -42,6 +43,8 @@ final class FaceVerifyImageProcessor { private static final int MAX_WIDTH = 1080; /** 阿里云允许的图片最大高度 */ private static final int MAX_HEIGHT = 1920; + /** 横图转竖图时使用的目标宽高比(宽:高),约等于 3:4 */ + private static final double PORTRAIT_ASPECT = 3D / 4D; /** JPEG 输出质量:0.9,用于兼顾人脸清晰度和文件大小 */ private static final float JPEG_QUALITY = 0.90F; @@ -85,9 +88,26 @@ final class FaceVerifyImageProcessor { throw new IllegalArgumentException("人脸认证图片格式不支持或文件已损坏"); } + // iPhone 竖拍常是「横像素 + EXIF Orientation」,必须先转正再判断竖/横图 + int orientation = readJpegExifOrientation(sourceImage); + if (orientation > 1) { + BufferedImage oriented = applyExifOrientation(decodedImage, orientation); + decodedImage.flush(); + decodedImage = oriented; + log.info("|人脸识别|,已按 EXIF Orientation={} 转正比对图", orientation); + } + int sourceWidth = decodedImage.getWidth(); int sourceHeight = decodedImage.getHeight(); - // 阿里云要求人脸比对照片为竖图;横图不做裁剪,避免裁掉人脸区域。 + // 阿里云要求人脸比对照片为竖图(高>宽);横图居中裁成竖图,避免直接失败 + if (sourceHeight <= sourceWidth) { + BufferedImage portrait = cropToPortrait(decodedImage); + decodedImage.flush(); + decodedImage = portrait; + sourceWidth = decodedImage.getWidth(); + sourceHeight = decodedImage.getHeight(); + log.info("|人脸识别|,横图已居中裁成竖图,尺寸={}x{}", sourceWidth, sourceHeight); + } if (sourceHeight <= sourceWidth) { decodedImage.flush(); throw new IllegalArgumentException("人脸认证图片必须为竖图,且高度大于宽度"); @@ -113,11 +133,175 @@ final class FaceVerifyImageProcessor { encodedImage = encodeJpeg(processedImage); } - log.info("|人脸识别|,图片尺寸处理完成,原始尺寸={}x{},处理后尺寸={}x{}", sourceWidth, sourceHeight, processedImage.getWidth(), processedImage.getHeight()); + log.info("|人脸识别|,图片尺寸处理完成,原始尺寸={}x{},处理后尺寸={}x{}", + sourceWidth, sourceHeight, processedImage.getWidth(), processedImage.getHeight()); processedImage.flush(); return encodedImage; } + /** + * 将横图居中裁成竖图(约 3:4),尽量保留中间人脸区域。 + */ + private static BufferedImage cropToPortrait(BufferedImage source) { + int width = source.getWidth(); + int height = source.getHeight(); + int cropHeight = height; + int cropWidth = (int) Math.floor(cropHeight * PORTRAIT_ASPECT); + if (cropWidth >= cropHeight) { + cropWidth = Math.max(1, cropHeight - 1); + } + if (cropWidth > width) { + cropWidth = width; + cropHeight = (int) Math.floor(cropWidth / PORTRAIT_ASPECT); + if (cropHeight > height) { + cropHeight = height; + } + if (cropHeight <= cropWidth) { + cropHeight = Math.min(height, cropWidth + 1); + } + } + int x = Math.max(0, (width - cropWidth) / 2); + int y = Math.max(0, (height - cropHeight) / 2); + cropWidth = Math.min(cropWidth, width - x); + cropHeight = Math.min(cropHeight, height - y); + return source.getSubimage(x, y, cropWidth, cropHeight); + } + + /** + * 解析 JPEG EXIF Orientation(Tag 0x0112),非 JPEG / 失败返回 1。 + */ + private static int readJpegExifOrientation(byte[] jpeg) { + try { + if (jpeg == null || jpeg.length < 4 || (jpeg[0] & 0xFF) != 0xFF || (jpeg[1] & 0xFF) != 0xD8) { + return 1; + } + int offset = 2; + while (offset + 4 < jpeg.length) { + if ((jpeg[offset] & 0xFF) != 0xFF) { + break; + } + int marker = jpeg[offset + 1] & 0xFF; + offset += 2; + if (marker == 0xD9 || marker == 0xDA) { + break; + } + if (offset + 2 > jpeg.length) { + break; + } + int segLen = ((jpeg[offset] & 0xFF) << 8) | (jpeg[offset + 1] & 0xFF); + if (segLen < 2 || offset + segLen > jpeg.length) { + break; + } + if (marker == 0xE1 && segLen >= 8) { + int exifStart = offset + 2; + if (exifStart + 6 <= jpeg.length + && jpeg[exifStart] == 'E' && jpeg[exifStart + 1] == 'x' + && jpeg[exifStart + 2] == 'i' && jpeg[exifStart + 3] == 'f' + && jpeg[exifStart + 4] == 0 && jpeg[exifStart + 5] == 0) { + int tiff = exifStart + 6; + boolean little = jpeg[tiff] == 'I' && jpeg[tiff + 1] == 'I'; + if (!(little || (jpeg[tiff] == 'M' && jpeg[tiff + 1] == 'M'))) { + return 1; + } + int ifd0 = tiff + readInt(jpeg, tiff + 4, 4, little); + if (ifd0 + 2 > jpeg.length) { + return 1; + } + int entries = readInt(jpeg, ifd0, 2, little); + for (int i = 0; i < entries; i++) { + int entry = ifd0 + 2 + i * 12; + if (entry + 12 > jpeg.length) { + break; + } + int tag = readInt(jpeg, entry, 2, little); + if (tag == 0x0112) { + return readInt(jpeg, entry + 8, 2, little); + } + } + } + } + offset += segLen; + } + } catch (Exception ignore) { + // ignore + } + return 1; + } + + private static int readInt(byte[] data, int off, int len, boolean littleEndian) { + int v = 0; + if (littleEndian) { + for (int i = 0; i < len; i++) { + v |= (data[off + i] & 0xFF) << (8 * i); + } + } else { + for (int i = 0; i < len; i++) { + v = (v << 8) | (data[off + i] & 0xFF); + } + } + return v; + } + + private static BufferedImage applyExifOrientation(BufferedImage src, int orientation) { + int w = src.getWidth(); + int h = src.getHeight(); + AffineTransform tx = new AffineTransform(); + int newW = w; + int newH = h; + switch (orientation) { + case 2: + tx.scale(-1, 1); + tx.translate(-w, 0); + break; + case 3: + tx.translate(w, h); + tx.rotate(Math.PI); + break; + case 4: + tx.scale(1, -1); + tx.translate(0, -h); + break; + case 5: + tx.rotate(Math.PI / 2); + tx.scale(-1, 1); + newW = h; + newH = w; + break; + case 6: // 90 CW —— iPhone 竖拍最常见 + tx.translate(h, 0); + tx.rotate(Math.PI / 2); + newW = h; + newH = w; + break; + case 7: + tx.scale(-1, 1); + tx.translate(-h, 0); + tx.translate(0, w); + tx.rotate(3 * Math.PI / 2); + newW = h; + newH = w; + break; + case 8: + tx.translate(0, w); + tx.rotate(3 * Math.PI / 2); + newW = h; + newH = w; + break; + default: + return src; + } + BufferedImage dst = new BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB); + Graphics2D g = dst.createGraphics(); + try { + g.setColor(Color.WHITE); + g.fillRect(0, 0, newW, newH); + g.drawImage(src, tx, null); + } finally { + g.dispose(); + } + return dst; + } + /** * 根据阿里云尺寸限制计算目标宽高,保持图片原始宽高比。 * 大图优先将宽度缩放至 720 像素,小图仅放大至接口要求的最小尺寸。 @@ -133,7 +317,8 @@ final class FaceVerifyImageProcessor { int targetWidth = Math.max(1, (int) Math.floor(sourceWidth * scale)); int targetHeight = Math.max(1, (int) Math.floor(sourceHeight * scale)); if (targetWidth < MIN_WIDTH || targetHeight < MIN_HEIGHT || targetWidth > MAX_WIDTH || targetHeight > MAX_HEIGHT) { - throw new IllegalArgumentException(String.format("人脸认证图片比例无法满足阿里云尺寸要求,原始尺寸:%dx%d", sourceWidth, sourceHeight)); + throw new IllegalArgumentException(String.format( + "人脸认证图片比例无法满足阿里云尺寸要求,原始尺寸:%dx%d", sourceWidth, sourceHeight)); } return new int[]{targetWidth, targetHeight}; } @@ -216,7 +401,6 @@ final class FaceVerifyImageProcessor { throw new IllegalArgumentException("人脸认证原始图片不能超过10MB"); } - // 即使服务端未返回 Content-Length,也在读取过程中限制总字节数,避免占用过多内存。 try (InputStream inputStream = connection.getInputStream(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream( contentLength > 0 ? Math.min(contentLength, MAX_SOURCE_IMAGE_BYTES) : 8192)) { diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ThirdFaceVerifyServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ThirdFaceVerifyServiceImpl.java index 51f408a..a2739eb 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ThirdFaceVerifyServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/service/ThirdFaceVerifyServiceImpl.java @@ -2,6 +2,9 @@ package cn.iocoder.yudao.module.member.client.service; import cn.hutool.core.bean.BeanUtil; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; import cn.iocoder.yudao.module.member.client.vo.thirdfaceverify.ThirdFindFaceVerifyReqVo; import cn.iocoder.yudao.module.member.client.vo.thirdfaceverify.ThirdFindFaceVerifyResVo; import cn.iocoder.yudao.module.member.client.vo.thirdfaceverify.ThirdFaceVerifyReqVO; @@ -56,8 +59,23 @@ public class ThirdFaceVerifyServiceImpl implements ThirdFaceVerifyService { // 参数 request.setOuterOrderNo(dto.getCode()); request.setUserId(String.valueOf(dto.getFaceVerifyUserId())); - request.setMetaInfo(dto.getMetaInfoJson()); - // request.setFaceContrastPictureUrl(dto.getFaceVerifyUrl()); + // MetaInfo 必须是客户端 getMetaInfos 原始字符串;错误包装会导致 SDK 不弹刷脸页 + String metaInfo = dto.getMetaInfoJson(); + if (StrUtil.isNotBlank(metaInfo) && metaInfo.trim().startsWith("{") && metaInfo.contains("\"metaInfoJson\"")) { + try { + JSONObject obj = JSONUtil.parseObj(metaInfo); + String nested = obj.getStr("metaInfoJson"); + if (StrUtil.isNotBlank(nested)) { + log.warn("|人脸识别|,检测到 MetaInfo 被包了一层 authExtraJson,已自动解开"); + metaInfo = nested; + } + } catch (Exception ignore) { + // keep original + } + } + request.setMetaInfo(metaInfo); + log.info("|人脸识别|,InitFaceVerify outerOrderNo={} userId={} metaLen={}", + dto.getCode(), dto.getFaceVerifyUserId(), metaInfo == null ? 0 : metaInfo.length()); request.setFaceContrastPicture(base64Image); // 配置 @@ -137,16 +155,58 @@ public class ThirdFaceVerifyServiceImpl implements ThirdFaceVerifyService { log.error("|人脸识别|,第三方接口无响应"); return null; } - ThirdFindFaceVerifyResVo result = new ThirdFindFaceVerifyResVo(); DescribeFaceVerifyResponseBody body = response.getBody(); - if (ObjectUtil.notEqual(body.getCode(), "200")) { - result.setErrorMessage(body.getMessage()); - } + ThirdFindFaceVerifyResVo result = new ThirdFindFaceVerifyResVo(); DescribeFaceVerifyResponseBody.DescribeFaceVerifyResponseBodyResultObject resultObject = body.getResultObject(); - result = BeanUtil.toBean(resultObject, ThirdFindFaceVerifyResVo.class); + if (ObjectUtil.isNotEmpty(resultObject)) { + result = BeanUtil.toBean(resultObject, ThirdFindFaceVerifyResVo.class); + if (result == null) { + result = new ThirdFindFaceVerifyResVo(); + } + } + if (ObjectUtil.notEqual(body.getCode(), "200")) { + // 接口级失败信息(不可被 ResultObject 拷贝覆盖) + result.setErrorMessage(body.getMessage()); + log.warn("|人脸识别|,DescribeFaceVerify 失败 code={} msg={} certifyId={}", + body.getCode(), body.getMessage(), code.getCertifyId()); + } else if (ObjectUtil.isEmpty(result.getErrorMessage())) { + result.setErrorMessage(describeSubCode(result.getSubCode(), result.getPassed())); + } + log.info("|人脸识别|,DescribeFaceVerify certifyId={} passed={} subCode={} err={}", + code.getCertifyId(), result.getPassed(), result.getSubCode(), result.getErrorMessage()); return result; } + /** 阿里云 ResultObject.SubCode → 可读原因(对齐官方错误码说明) */ + private static String describeSubCode(String subCode, String passed) { + if (ObjectUtil.equals("T", passed) || ObjectUtil.equals("200", subCode)) { + return "认证通过"; + } + if (ObjectUtil.isEmpty(subCode)) { + return ObjectUtil.equals("F", passed) ? "认证未通过" : null; + } + switch (subCode) { + case "201": + return "姓名和身份证不一致"; + case "202": + return "查询不到身份信息"; + case "203": + return "查询不到照片或照片不可用"; + case "204": + return "人脸比对不一致(与上传头像不是同一人或活体质量偏低)"; + case "205": + return "活体检测存在风险"; + case "206": + return "业务策略限制(设备环境异常/高频认证等)"; + case "207": + return "人脸与身份证人脸比对不一致"; + case "209": + return "权威比对源异常"; + default: + return "认证未通过(subCode=" + subCode + ")"; + } + } + private DescribeFaceVerifyResponse describeFaceVerifyAutoRoute(DescribeFaceVerifyRequest request) { DescribeFaceVerifyResponse lastResponse = null; for (int i = 0; i < endpoints.size(); i++) { diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/tool/ChatApiTool.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/tool/ChatApiTool.java index d4f8731..3d2e0a4 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/tool/ChatApiTool.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/tool/ChatApiTool.java @@ -21,6 +21,7 @@ public class ChatApiTool { public static final API USER_LOGIN = API.builder().summary("用户登录").url("/third/user/login").method(HttpMethod.POST).build(); public static final API USER_GET = API.builder().summary("根据userId查询用户详情").url("/third/user/getUserByUserId").method(HttpMethod.POST).build(); public static final API USER_SAVE_OR_UPDATE = API.builder().summary("创建或者更新用户").url("/third/user/createOrUpdate").method(HttpMethod.POST).build(); + public static final API USER_BAN = API.builder().summary("封禁用户并踢下线").url("/third/user/ban").method(HttpMethod.POST).build(); public static final API GET_USER_ONLINE_STATUS_BY_ID = API.builder().summary("查询用户在线状态").url("/third/user/getUserOnlineStatusById").method(HttpMethod.POST).build(); // 朋友圈 diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/user/ChatBanUserReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/user/ChatBanUserReqVO.java new file mode 100644 index 0000000..f7ab04d --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/client/vo/chat/user/ChatBanUserReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.member.client.vo.chat.user; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotNull; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "封禁聊天用户") +public class ChatBanUserReqVO { + + @NotNull(message = "用户id不可为空") + @Schema(description = "用户id") + private Long userId; + + @Schema(description = "封禁原因") + private String reason; +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/MemberAirlineAccountController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/MemberAirlineAccountController.java new file mode 100644 index 0000000..82df45f --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/MemberAirlineAccountController.java @@ -0,0 +1,96 @@ +package cn.iocoder.yudao.module.member.controller.admin.airline; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.member.controller.admin.airline.vo.*; +import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlineAccountDO; +import cn.iocoder.yudao.module.member.service.airline.MemberAirlineAccountService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.validation.Valid; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - 航空账号") +@RestController +@RequestMapping("/member/airline-account") +@Validated +public class MemberAirlineAccountController { + + @Resource + private MemberAirlineAccountService airlineAccountService; + + @PostMapping("/create") + @Operation(summary = "创建航空账号") + @PreAuthorize("@ss.hasPermission('member:airline-account:create')") + public CommonResult createAirlineAccount(@Valid @RequestBody MemberAirlineAccountCreateReqVO createReqVO) { + return success(airlineAccountService.createAirlineAccount(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新航空账号") + @PreAuthorize("@ss.hasPermission('member:airline-account:update')") + public CommonResult updateAirlineAccount(@Valid @RequestBody MemberAirlineAccountUpdateReqVO updateReqVO) { + airlineAccountService.updateAirlineAccount(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除航空账号") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('member:airline-account:delete')") + public CommonResult deleteAirlineAccount(@RequestParam("id") Long id) { + airlineAccountService.deleteAirlineAccount(id); + return success(true); + } + + @PutMapping("/unbind-device") + @Operation(summary = "解绑设备(换机后需重新登录绑定)") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('member:airline-account:unbind')") + public CommonResult unbindDevice(@RequestParam("id") Long id) { + airlineAccountService.unbindDevice(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得航空账号") + @Parameter(name = "id", description = "编号", required = true, example = "1") + @PreAuthorize("@ss.hasPermission('member:airline-account:query')") + public CommonResult getAirlineAccount(@RequestParam("id") Long id) { + return success(convert(airlineAccountService.getAirlineAccount(id))); + } + + @GetMapping("/page") + @Operation(summary = "获得航空账号分页") + @PreAuthorize("@ss.hasPermission('member:airline-account:query')") + public CommonResult> getAirlineAccountPage( + @Valid MemberAirlineAccountPageReqVO pageReqVO) { + PageResult pageResult = airlineAccountService.getAirlineAccountPage(pageReqVO); + List list = pageResult.getList().stream() + .map(this::convert).collect(Collectors.toList()); + return success(new PageResult<>(list, pageResult.getTotal())); + } + + private MemberAirlineAccountRespVO convert(MemberAirlineAccountDO account) { + if (account == null) { + return null; + } + MemberAirlineAccountRespVO vo = BeanUtils.toBean(account, MemberAirlineAccountRespVO.class); + LocalDateTime now = LocalDateTime.now(); + vo.setExpired(account.getEndTime() != null && now.isAfter(account.getEndTime())); + vo.setNotStarted(account.getStartTime() != null && now.isBefore(account.getStartTime())); + return vo; + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/vo/MemberAirlineAccountBaseVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/vo/MemberAirlineAccountBaseVO.java new file mode 100644 index 0000000..3fc137a --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/vo/MemberAirlineAccountBaseVO.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.member.controller.admin.airline.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.hibernate.validator.constraints.Length; +import org.springframework.format.annotation.DateTimeFormat; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 航空账号新增/修改 Base VO") +@Data +public class MemberAirlineAccountBaseVO { + + @Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13800138000") + @NotEmpty(message = "手机号不能为空") + @Length(min = 11, max = 11, message = "手机号长度为 11 位") + private String mobile; + + @Schema(description = "品牌渠道(CZ南航/MU东航/HU海航)", requiredMode = Schema.RequiredMode.REQUIRED, example = "CZ") + @NotEmpty(message = "品牌渠道不能为空") + @Pattern(regexp = "CZ|MU|HU", message = "品牌渠道仅支持 CZ(南航)/ MU(东航)/ HU(海航)") + private String channel; + + @Schema(description = "生效开始时间", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "开始时间不能为空") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime startTime; + + @Schema(description = "生效结束时间", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "结束时间不能为空") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime endTime; + + @Schema(description = "状态(0开启 1关闭)", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + private Integer status; + + @Schema(description = "备注", example = "某某航司临时账号") + private String remark; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/vo/MemberAirlineAccountCreateReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/vo/MemberAirlineAccountCreateReqVO.java new file mode 100644 index 0000000..035b141 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/vo/MemberAirlineAccountCreateReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.member.controller.admin.airline.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.hibernate.validator.constraints.Length; + +import javax.validation.constraints.NotEmpty; + +@Schema(description = "管理后台 - 航空账号创建 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class MemberAirlineAccountCreateReqVO extends MemberAirlineAccountBaseVO { + + @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") + @NotEmpty(message = "密码不能为空") + @Length(min = 4, max = 16, message = "密码长度为 4-16 位") + private String password; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/vo/MemberAirlineAccountPageReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/vo/MemberAirlineAccountPageReqVO.java new file mode 100644 index 0000000..49fb3a5 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/vo/MemberAirlineAccountPageReqVO.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.member.controller.admin.airline.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - 航空账号分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class MemberAirlineAccountPageReqVO extends PageParam { + + @Schema(description = "手机号", example = "13800138000") + private String mobile; + + @Schema(description = "品牌渠道(CZ南航/MU东航/HU海航)", example = "CZ") + private String channel; + + @Schema(description = "状态(0开启 1关闭)", example = "0") + private Integer status; + + @Schema(description = "设备唯一识别号", example = "A1B2C3D4") + private String deviceId; + + @Schema(description = "创建时间") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/vo/MemberAirlineAccountRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/vo/MemberAirlineAccountRespVO.java new file mode 100644 index 0000000..4268a5f --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/vo/MemberAirlineAccountRespVO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.member.controller.admin.airline.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 航空账号 Response VO") +@Data +public class MemberAirlineAccountRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long id; + + @Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13800138000") + private String mobile; + + @Schema(description = "品牌渠道(CZ南航/MU东航/HU海航)", requiredMode = Schema.RequiredMode.REQUIRED, example = "CZ") + private String channel; + + @Schema(description = "生效开始时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime startTime; + + @Schema(description = "生效结束时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime endTime; + + @Schema(description = "设备唯一识别号", example = "A1B2C3D4") + private String deviceId; + + @Schema(description = "状态(0开启 1关闭)", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "关联会员用户编号") + private Long userId; + + @Schema(description = "是否已过期") + private Boolean expired; + + @Schema(description = "是否尚未生效") + private Boolean notStarted; + + @Schema(description = "创建时间") + private LocalDateTime createTime; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/vo/MemberAirlineAccountUpdateReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/vo/MemberAirlineAccountUpdateReqVO.java new file mode 100644 index 0000000..2f014f6 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/airline/vo/MemberAirlineAccountUpdateReqVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.member.controller.admin.airline.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.hibernate.validator.constraints.Length; + +import javax.validation.constraints.NotNull; + +@Schema(description = "管理后台 - 航空账号更新 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class MemberAirlineAccountUpdateReqVO extends MemberAirlineAccountBaseVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "密码(不传或空则不修改)", example = "123456") + @Length(min = 4, max = 16, message = "密码长度为 4-16 位") + private String password; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java index 2b6d1e0..75ccf12 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/MemberUserController.java @@ -71,6 +71,14 @@ public class MemberUserController { return success(true); } + @PutMapping("/update-status") + @Operation(summary = "更新会员用户状态(禁用后踢出 App 登录,且无法再登录)") + @PreAuthorize("@ss.hasPermission('member:user:update')") + public CommonResult updateUserStatus(@Valid @RequestBody MemberUserUpdateStatusReqVO reqVO) { + memberUserService.updateUserStatus(reqVO.getId(), reqVO.getStatus()); + return success(true); + } + @GetMapping("/fill-missing-user-codes") @Operation(summary = "为历史会员补齐用户 Code") // @PreAuthorize("@ss.hasPermission('member:user:update')") diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserPageReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserPageReqVO.java index abb9428..ee5bc15 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserPageReqVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserPageReqVO.java @@ -41,6 +41,12 @@ public class MemberUserPageReqVO extends PageParam { @Schema(description = "用户分组编号", example = "1") private Long groupId; + @Schema(description = "用户 Code(App 我的页面展示的 ID)", example = "924199") + private String userCode; + + @Schema(description = "性别:1 男 2 女", example = "1") + private Integer sex; + // TODO 芋艿:注册用户类型; // TODO 芋艿:登录用户类型; diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserUpdateStatusReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserUpdateStatusReqVO.java new file mode 100644 index 0000000..689c912 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserUpdateStatusReqVO.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.member.controller.admin.user.vo; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotNull; + +@Schema(description = "管理后台 - 会员用户更新状态 Request VO") +@Data +public class MemberUserUpdateStatusReqVO { + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "用户编号不能为空") + private Long id; + + @Schema(description = "状态,见 CommonStatusEnum 枚举。0 开启,1 禁用", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "状态不能为空") + @InEnum(value = CommonStatusEnum.class, message = "修改状态必须是 {value}") + private Integer status; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/airline/AppAirlineDataController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/airline/AppAirlineDataController.java new file mode 100644 index 0000000..fe7c0e8 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/airline/AppAirlineDataController.java @@ -0,0 +1,97 @@ +package cn.iocoder.yudao.module.member.controller.app.airline; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.member.controller.app.airline.vo.AirlineFlightVO; +import cn.iocoder.yudao.module.member.controller.app.airline.vo.AirlinePointVO; +import cn.iocoder.yudao.module.member.service.airline.MemberAirlineDataService; +import io.swagger.v3.oas.annotations.Operation; +import javax.annotation.security.PermitAll; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 免登录接口(@PermitAll):身份由 mobile + channel + deviceId 三要素定位, + * 与 airline-check 相同的账号状态/有效期/设备绑定校验,防冒用。 + */ +@Tag(name = "用户 APP - 航空 App 数据云同步(自建航线/我的积点钱包)") +@RestController +@RequestMapping("/member/airline-data") +@Validated +public class AppAirlineDataController { + + @Resource + private MemberAirlineDataService airlineDataService; + + // ─── 航线 ──────────────────────────────────────────────── + + @GetMapping("/flight/list") + @Operation(summary = "航线列表") + @PermitAll + public CommonResult> flightList(@RequestParam("mobile") String mobile, + @RequestParam("deviceId") String deviceId, + @RequestParam("channel") String channel) { + return success(airlineDataService.getFlightList(mobile, deviceId, channel)); + } + + @PostMapping("/flight/create") + @Operation(summary = "新增航线") + @PermitAll + public CommonResult flightCreate(@RequestParam("mobile") String mobile, + @RequestParam("deviceId") String deviceId, + @RequestParam("channel") String channel, + @RequestBody AirlineFlightVO vo) { + return success(airlineDataService.createFlight(mobile, deviceId, channel, vo)); + } + + @PutMapping("/flight/update") + @Operation(summary = "更新航线") + @PermitAll + public CommonResult flightUpdate(@RequestParam("mobile") String mobile, + @RequestParam("deviceId") String deviceId, + @RequestParam("channel") String channel, + @RequestBody AirlineFlightVO vo) { + airlineDataService.updateFlight(mobile, deviceId, channel, vo); + return success(true); + } + + @DeleteMapping("/flight/delete") + @Operation(summary = "删除航线") + @PermitAll + public CommonResult flightDelete(@RequestParam("mobile") String mobile, + @RequestParam("deviceId") String deviceId, + @RequestParam("channel") String channel, + @RequestParam("id") Long id) { + airlineDataService.deleteFlight(mobile, deviceId, channel, id); + return success(true); + } + + // ─── 我的:积点/钱包 ───────────────────────────────────── + + @GetMapping("/point/get") + @Operation(summary = "我的积点/钱包") + @PermitAll + public CommonResult pointGet(@RequestParam("mobile") String mobile, + @RequestParam("deviceId") String deviceId, + @RequestParam("channel") String channel) { + return success(airlineDataService.getPoint(mobile, deviceId, channel)); + } + + @PutMapping("/point/update") + @Operation(summary = "保存我的积点/钱包") + @PermitAll + public CommonResult pointUpdate(@RequestParam("mobile") String mobile, + @RequestParam("deviceId") String deviceId, + @RequestParam("channel") String channel, + @RequestBody AirlinePointVO vo) { + airlineDataService.updatePoint(mobile, deviceId, channel, vo); + return success(true); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/airline/vo/AirlineFlightVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/airline/vo/AirlineFlightVO.java new file mode 100644 index 0000000..ae36515 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/airline/vo/AirlineFlightVO.java @@ -0,0 +1,91 @@ +package cn.iocoder.yudao.module.member.controller.app.airline.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "用户 APP - 航空 App 自建航线 VO") +@Data +public class AirlineFlightVO { + + @Schema(description = "编号(新增时不传,更新时必传)", example = "1") + private Long id; + + @Schema(description = "航班号", example = "CZ3101") + private String flightNo; + + @Schema(description = "任务/航班名称") + private String title; + + @Schema(description = "出发城市") + private String beginCity; + + @Schema(description = "出发机场") + private String beginAirport; + + @Schema(description = "到达城市") + private String endCity; + + @Schema(description = "到达机场") + private String endAirport; + + @Schema(description = "舱位/班次") + private String classes; + + @Schema(description = "状态") + private String status; + + @Schema(description = "计划起飞时间") + private String flyBeginDate0; + + @Schema(description = "计划到达时间") + private String flyEndDate0; + + @Schema(description = "实际起飞时间") + private String flyBeginDate1; + + @Schema(description = "实际到达时间") + private String flyEndDate1; + + @Schema(description = "签到时间") + private String signInTime; + + @Schema(description = "机号/编号") + private String aircraftNo; + + @Schema(description = "航司") + private String airline; + + @Schema(description = "机型") + private String aircraftType; + + @Schema(description = "登机口") + private String gate; + + @Schema(description = "出发机场停机位") + private String beginStand; + + @Schema(description = "到达机场停机位") + private String endStand; + + @Schema(description = "货关时间") + private String cargoClose; + + @Schema(description = "客关时间") + private String paxClose; + + @Schema(description = "离港时间") + private String leavePort; + + @Schema(description = "起飞时间") + private String takeOff; + + @Schema(description = "落地时间") + private String land; + + @Schema(description = "到港时间") + private String arrivePort; + + @Schema(description = "前序航班") + private String prevFlight; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/airline/vo/AirlinePointVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/airline/vo/AirlinePointVO.java new file mode 100644 index 0000000..92e458c --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/airline/vo/AirlinePointVO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.member.controller.app.airline.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "用户 APP - 航空 App 我的积点/钱包 VO") +@Data +public class AirlinePointVO { + + @Schema(description = "当月飞行", example = "12") + private String currentMonth; + + @Schema(description = "年度飞行", example = "130") + private String currentYear; + + @Schema(description = "绩效积点", example = "08") + private String jixiao; + + @Schema(description = "延误积点", example = "00") + private String yanwu; + + @Schema(description = "当前积点", example = "260") + private String currentPoint; + + @Schema(description = "钱包余额", example = "1280.00") + private String wallet; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/auth/AppAuthController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/auth/AppAuthController.java index 338a4cd..067278a 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/auth/AppAuthController.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/auth/AppAuthController.java @@ -49,6 +49,27 @@ public class AppAuthController { return success(authService.login(reqVO)); } + @PostMapping("/airline-login") + @Operation(summary = "航空账号登录(手机 + 密码 + 设备唯一识别号)") + @PermitAll + public CommonResult airlineLogin(@RequestBody @Valid AppAuthAirlineLoginReqVO reqVO) { + return success(authService.airlineLogin(reqVO)); + } + + @GetMapping("/airline-check") + @Operation(summary = "航空账号会话校验(免登录,兼容新旧客户端:Token/手机号+渠道/设备识别号 任一定位)") + @Parameter(name = "deviceId", description = "设备唯一识别号", required = true) + @Parameter(name = "mobile", description = "手机号(新客户端传)") + @Parameter(name = "channel", description = "品牌渠道 CZ/MU/HU(新客户端传)") + @PermitAll + public CommonResult airlineCheck( + @RequestParam(value = "mobile", required = false) String mobile, + @RequestParam("deviceId") String deviceId, + @RequestParam(value = "channel", required = false) String channel) { + authService.airlineCheck(getLoginUserId(), mobile, deviceId, channel); + return success(true); + } + @PostMapping("/logout") @Operation(summary = "登出系统") @PermitAll diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/auth/vo/AppAuthAirlineLoginReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/auth/vo/AppAuthAirlineLoginReqVO.java new file mode 100644 index 0000000..c787f0a --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/auth/vo/AppAuthAirlineLoginReqVO.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.member.controller.app.auth.vo; + +import cn.iocoder.yudao.framework.common.validation.Mobile; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.hibernate.validator.constraints.Length; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.Pattern; + +@Schema(description = "用户 APP - 航空账号登录 Request VO") +@Data +public class AppAuthAirlineLoginReqVO { + + @Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13800138000") + @NotEmpty(message = "手机号不能为空") + @Mobile + private String mobile; + + @Schema(description = "品牌渠道", requiredMode = Schema.RequiredMode.REQUIRED, example = "CZ") + @NotEmpty(message = "品牌渠道不能为空") + @Pattern(regexp = "CZ|MU|HU", message = "品牌渠道仅支持 CZ(南航)/ MU(东航)/ HU(海航)") + private String channel; + + @Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") + @NotEmpty(message = "密码不能为空") + @Length(min = 4, max = 16, message = "密码长度为 4-16 位") + private String password; + + @Schema(description = "手机唯一识别号(重装/重启后须保持不变)", requiredMode = Schema.RequiredMode.REQUIRED, + example = "android-id-or-keychain-uuid") + @NotEmpty(message = "设备唯一识别号不能为空") + @Length(min = 8, max = 128, message = "设备唯一识别号长度为 8-128 位") + private String deviceId; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/chat/AppChatController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/chat/AppChatController.java index eefd935..38f8952 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/chat/AppChatController.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/chat/AppChatController.java @@ -6,6 +6,7 @@ import cn.iocoder.yudao.module.member.client.vo.chat.friend.ChatAddFriendReqVO; import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatPrivateMessageSendReqVO; import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatPrivateMessageSendResVO; import cn.iocoder.yudao.module.member.service.opentype.OpenTypeService; +import cn.iocoder.yudao.module.member.service.push.MemberPushService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.validation.annotation.Validated; @@ -27,6 +28,8 @@ public class AppChatController { @Resource private ChatApi chatApi; + @Resource + private MemberPushService memberPushService; @PostMapping("/send-private-msg") @Operation(summary = "发送私聊消息(有会员限制)") @@ -35,7 +38,11 @@ public class AppChatController { openTypeService.isUserCanSendMessage(sendId, body.getRecvId()); body.setSendId(sendId); body.setTerminal(1); - return success(chatApi.send(body)); + ChatPrivateMessageSendResVO result = chatApi.send(body); + if (result != null) { + memberPushService.pushPrivateMessage(body); + } + return success(result); } @GetMapping("/add-friend") diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/comauth/AppComAuthController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/comauth/AppComAuthController.java index 457b332..71c50e1 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/comauth/AppComAuthController.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/comauth/AppComAuthController.java @@ -8,6 +8,7 @@ import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateManAuthReq import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWechatAuthReqVo; import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWechatErrorAuthReqVo; import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWomanAuthReqVo; +import cn.iocoder.yudao.module.member.client.vo.thirdfaceverify.ThirdFindFaceVerifyResVo; import cn.iocoder.yudao.module.member.dal.dataobject.comauth.ComAuthDO; import cn.iocoder.yudao.module.member.service.comauth.ComAuthService; import io.swagger.v3.oas.annotations.Operation; @@ -47,12 +48,6 @@ public class AppComAuthController { return success(comAuthService.createWechatAuth(dto)); } - @PostMapping("/create-man-auth") - @Operation(summary = "创建男性认证") - public CommonResult createManAuth(@Valid @RequestBody CreateManAuthReqVo dto) { - dto.setMemberId(getLoginUserId()); - return success(comAuthService.createManAuth(dto)); - } @PostMapping("/create-woman-auth") @Operation(summary = "创建女性认证") @@ -81,4 +76,10 @@ public class AppComAuthController { return success(true); } + @GetMapping("/find-face-verify") + @Operation(summary = "查询阿里云人脸认证结果(SDK 失败排查)") + public CommonResult findFaceVerify(@RequestParam("authId") Long authId) { + return success(comAuthService.findFaceVerify(authId, getLoginUserId())); + } + } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/comauth/vo/CreateWechatAuthReqVo.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/comauth/vo/CreateWechatAuthReqVo.java index 0ebdf59..f25cfab 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/comauth/vo/CreateWechatAuthReqVo.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/comauth/vo/CreateWechatAuthReqVo.java @@ -19,8 +19,7 @@ public class CreateWechatAuthReqVo { @NotEmpty(message = "微信号不能为空") private String wechatNumber; - @Schema(description = "微信二维码") - @NotEmpty(message = "微信二维码不能为空") + @Schema(description = "微信二维码(选填)") private String wechatQrcode; } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/interaction/AppMemberInteractionController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/interaction/AppMemberInteractionController.java new file mode 100644 index 0000000..3001b3c --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/interaction/AppMemberInteractionController.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.member.controller.app.interaction; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.controller.app.interaction.vo.AppInteractionRespVO; +import cn.iocoder.yudao.module.member.controller.app.interaction.vo.AppInteractionSummaryRespVO; +import cn.iocoder.yudao.module.member.service.interaction.MemberInteractionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import javax.validation.Valid; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "用户 App - 互动消息") +@RestController +@RequestMapping("/member/interaction") +@Validated +public class AppMemberInteractionController { + + @Resource + private MemberInteractionService interactionService; + + @GetMapping("/summary") + @Operation(summary = "互动消息摘要(未读数 + 最近一条)") + public CommonResult getSummary() { + return success(interactionService.getSummary(getLoginUserId())); + } + + @GetMapping("/page") + @Operation(summary = "互动消息分页") + public CommonResult> getPage(@Valid PageParam pageParam) { + return success(interactionService.getPage(getLoginUserId(), pageParam)); + } + + @PostMapping("/read-all") + @Operation(summary = "全部标为已读") + public CommonResult readAll() { + interactionService.readAll(getLoginUserId()); + return success(true); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/interaction/vo/AppInteractionRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/interaction/vo/AppInteractionRespVO.java new file mode 100644 index 0000000..e8b4d15 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/interaction/vo/AppInteractionRespVO.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.member.controller.app.interaction.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "用户 App - 互动消息 Response VO") +@Data +public class AppInteractionRespVO { + + @Schema(description = "编号") + private Long id; + + @Schema(description = "类型:VIEWED / WECHAT_UNLOCKED") + private String bizType; + + @Schema(description = "文案,如「张三 查看了你」") + private String content; + + @Schema(description = "操作人编号") + private Long actorId; + + @Schema(description = "操作人头像") + private String actorAvatar; + + @Schema(description = "操作人昵称") + private String actorNickname; + + @Schema(description = "是否已读") + private Boolean readStatus; + + @Schema(description = "创建时间") + private LocalDateTime createTime; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/interaction/vo/AppInteractionSummaryRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/interaction/vo/AppInteractionSummaryRespVO.java new file mode 100644 index 0000000..93443a8 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/interaction/vo/AppInteractionSummaryRespVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.member.controller.app.interaction.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "用户 App - 互动消息摘要 Response VO") +@Data +public class AppInteractionSummaryRespVO { + + @Schema(description = "未读数") + private Long unreadCount; + + @Schema(description = "最近一条文案") + private String lastContent; + + @Schema(description = "最近一条时间") + private LocalDateTime lastTime; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/push/AppMemberPushController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/push/AppMemberPushController.java new file mode 100644 index 0000000..e7834e0 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/push/AppMemberPushController.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.member.controller.app.push; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.member.controller.app.push.vo.AppPushBindReqVO; +import cn.iocoder.yudao.module.member.controller.app.push.vo.AppPushUnbindReqVO; +import cn.iocoder.yudao.module.member.service.push.MemberPushDeviceService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import javax.validation.Valid; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "用户 App - 推送设备") +@RestController +@RequestMapping("/member/push") +@Validated +public class AppMemberPushController { + + @Resource + private MemberPushDeviceService pushDeviceService; + + @PostMapping("/bind") + @Operation(summary = "绑定推送设备(极光 RegistrationID)") + public CommonResult bind(@Valid @RequestBody AppPushBindReqVO reqVO) { + pushDeviceService.bind(getLoginUserId(), reqVO); + return success(true); + } + + @PostMapping("/unbind") + @Operation(summary = "解绑推送设备") + public CommonResult unbind(@Valid @RequestBody AppPushUnbindReqVO reqVO) { + pushDeviceService.unbind(getLoginUserId(), reqVO.getRegistrationId()); + return success(true); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/push/vo/AppPushBindReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/push/vo/AppPushBindReqVO.java new file mode 100644 index 0000000..4046c3f --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/push/vo/AppPushBindReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.member.controller.app.push.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +@Schema(description = "用户 App - 推送设备绑定 Request VO") +@Data +public class AppPushBindReqVO { + + @Schema(description = "极光 RegistrationID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1a2b3c") + @NotBlank(message = "registrationId 不能为空") + private String registrationId; + + @Schema(description = "极光别名", example = "u1024") + private String alias; + + @Schema(description = "平台:ios / android", requiredMode = Schema.RequiredMode.REQUIRED, example = "android") + @NotBlank(message = "platform 不能为空") + private String platform; + + @Schema(description = "推送服务商", example = "jpush") + private String provider; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/push/vo/AppPushUnbindReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/push/vo/AppPushUnbindReqVO.java new file mode 100644 index 0000000..e5d4c3a --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/push/vo/AppPushUnbindReqVO.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.member.controller.app.push.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +@Schema(description = "用户 App - 推送设备解绑 Request VO") +@Data +public class AppPushUnbindReqVO { + + @Schema(description = "极光 RegistrationID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1a2b3c") + @NotBlank(message = "registrationId 不能为空") + private String registrationId; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/AppRidePlanController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/AppRidePlanController.java new file mode 100644 index 0000000..018325b --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/AppRidePlanController.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanCreateReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanPageReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanRespVO; +import cn.iocoder.yudao.module.member.service.rideplan.MemberRidePlanService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import javax.annotation.security.PermitAll; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.validation.Valid; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +/** + * 骑遇发车局: + * - 查询(page/get)@PermitAll 免登录,游客可浏览, RespVO 已做字段脱敏(只暴露昵称/头像) + * - 发布/加入/取消加入/举报 需登录(/app-api/** 默认拦截,未带有效 token 返回 401) + */ +@Tag(name = "用户 APP - 骑遇发车局") +@RestController +@RequestMapping("/member/ride-plan") +@Validated +public class AppRidePlanController { + + @Resource + private MemberRidePlanService ridePlanService; + + @GetMapping("/page") + @Operation(summary = "发车局分页(免登录)", description = "游客可查;登录后返回 joinedByMe") + @PermitAll + public CommonResult> page(@Valid AppRidePlanPageReqVO reqVO) { + return success(ridePlanService.getRidePlanPage(reqVO, getLoginUserId())); + } + + @GetMapping("/get") + @Operation(summary = "发车局详情(免登录)") + @Parameter(name = "id", description = "发车局编号", required = true, example = "1") + @PermitAll + public CommonResult get(@RequestParam("id") Long id) { + return success(ridePlanService.getRidePlan(id, getLoginUserId())); + } + + @PostMapping("/create") + @Operation(summary = "发布发车局(需登录)") + public CommonResult create(@Valid @RequestBody AppRidePlanCreateReqVO reqVO) { + return success(ridePlanService.createRidePlan(getLoginUserId(), reqVO)); + } + + @PostMapping("/join") + @Operation(summary = "加入发车局(需登录)") + @Parameter(name = "planId", description = "发车局编号", required = true, example = "1") + public CommonResult join(@RequestParam("planId") Long planId) { + ridePlanService.joinRidePlan(getLoginUserId(), planId); + return success(true); + } + + @PostMapping("/cancel-join") + @Operation(summary = "取消加入发车局(需登录)") + @Parameter(name = "planId", description = "发车局编号", required = true, example = "1") + public CommonResult cancelJoin(@RequestParam("planId") Long planId) { + ridePlanService.cancelJoinRidePlan(getLoginUserId(), planId); + return success(true); + } + + @PostMapping("/report") + @Operation(summary = "举报发车局(需登录),达到阈值自动下架") + @Parameter(name = "planId", description = "发车局编号", required = true, example = "1") + public CommonResult report(@RequestParam("planId") Long planId) { + ridePlanService.reportRidePlan(getLoginUserId(), planId); + return success(true); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/AppRideRecordController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/AppRideRecordController.java new file mode 100644 index 0000000..d0ffca4 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/AppRideRecordController.java @@ -0,0 +1,105 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordCreateReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordPageReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordRespVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordStatsRespVO; +import cn.iocoder.yudao.module.member.service.rideplan.MemberRideRecordService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import javax.annotation.security.PermitAll; +import javax.validation.Valid; +import javax.validation.constraints.Size; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +/** + * 骑遇骑行记录(战报): + * - 战报墙 page / 详情 get / 天梯榜 rank @PermitAll 免登录,游客可浏览,RespVO 已做字段脱敏(只暴露昵称/头像) + * - 上报 create / 点赞 like / 举报 report 需登录(/app-api/** 默认拦截,未带有效 token 返回 401) + */ +@Tag(name = "用户 APP - 骑遇骑行记录") +@RestController +@RequestMapping("/member/ride-record") +@Validated +public class AppRideRecordController { + + @Resource + private MemberRideRecordService rideRecordService; + + @PostMapping("/create") + @Operation(summary = "上报骑行战报(需登录)", description = "完骑后提交;cornerName 有值则参与天梯榜") + public CommonResult create(@Valid @RequestBody AppRideRecordCreateReqVO reqVO) { + return success(rideRecordService.createRideRecord(getLoginUserId(), reqVO)); + } + + @GetMapping("/page") + @Operation(summary = "战报墙分页(免登录)", description = "游客可查;登录后返回 likedByMe") + @PermitAll + public CommonResult> page(@Valid AppRideRecordPageReqVO reqVO) { + return success(rideRecordService.getRideRecordPage(reqVO, getLoginUserId())); + } + + @GetMapping("/my") + @Operation(summary = "我的骑行记录分页(需登录)") + public CommonResult> my(@Valid AppRideRecordPageReqVO reqVO) { + return success(rideRecordService.getMyRideRecordPage(getLoginUserId(), reqVO)); + } + + @GetMapping("/get") + @Operation(summary = "战报详情(免登录,含轨迹)") + @Parameter(name = "id", description = "战报编号", required = true, example = "1") + @PermitAll + public CommonResult get(@RequestParam("id") Long id) { + return success(rideRecordService.getRideRecord(id, getLoginUserId())); + } + + @GetMapping("/rank") + @Operation(summary = "弯道天梯榜(免登录)", description = "按路线+弯道分组,组内按压弯角度降序;支持过滤") + @PermitAll + public CommonResult> rank( + @RequestParam(value = "roadName", required = false) + @Size(max = 50, message = "路线名长度不能超过 50") String roadName, + @RequestParam(value = "cornerName", required = false) + @Size(max = 50, message = "弯道名长度不能超过 50") String cornerName) { + return success(rideRecordService.getCornerRank(roadName, cornerName)); + } + + @GetMapping("/stats") + @Operation(summary = "批量骑手战报统计(免登录)", description = "返回每个骑手的战报数/最佳压弯/总里程;最多 100 人") + @PermitAll + public CommonResult> stats( + @RequestParam("userIds") @Size(max = 100, message = "一次最多查询 100 个骑手") List userIds) { + return success(rideRecordService.getRideRecordStats(userIds)); + } + + @PostMapping("/like") + @Operation(summary = "点赞/取消点赞(需登录)", description = "幂等切换,返回操作后是否处于点赞状态") + @Parameter(name = "recordId", description = "战报编号", required = true, example = "1") + public CommonResult like(@RequestParam("recordId") Long recordId) { + return success(rideRecordService.toggleLike(getLoginUserId(), recordId)); + } + + @PostMapping("/report") + @Operation(summary = "举报战报(需登录)", description = "达到阈值自动下架") + @Parameter(name = "recordId", description = "战报编号", required = true, example = "1") + public CommonResult report(@RequestParam("recordId") Long recordId) { + rideRecordService.reportRideRecord(getLoginUserId(), recordId); + return success(true); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/AppRideTagController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/AppRideTagController.java new file mode 100644 index 0000000..8374893 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/AppRideTagController.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideTagRespVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideUserTagUpdateReqVO; +import cn.iocoder.yudao.module.member.service.rideplan.MemberRidePlanService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import javax.annotation.security.PermitAll; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.validation.Valid; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +/** + * 骑遇骑行标签: + * - 标签字典 list @PermitAll 免登录(发局选标签、列表展示用) + * - 我的标签读写 需登录 + */ +@Tag(name = "用户 APP - 骑遇骑行标签") +@RestController +@RequestMapping("/member/ride-tag") +@Validated +public class AppRideTagController { + + @Resource + private MemberRidePlanService ridePlanService; + + @GetMapping("/list") + @Operation(summary = "骑行标签字典(免登录,只返回启用的)") + @PermitAll + public CommonResult> list() { + return success(ridePlanService.getRideTagList()); + } + + @GetMapping("/my") + @Operation(summary = "我的骑行标签(需登录)") + public CommonResult> my() { + return success(ridePlanService.getMyRideTags(getLoginUserId())); + } + + @PutMapping("/my") + @Operation(summary = "全量替换我的骑行标签(需登录)") + public CommonResult updateMy(@Valid @RequestBody AppRideUserTagUpdateReqVO reqVO) { + ridePlanService.updateMyRideTags(getLoginUserId(), reqVO); + return success(true); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRidePlanCreateReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRidePlanCreateReqVO.java new file mode 100644 index 0000000..ed7f654 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRidePlanCreateReqVO.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "用户 APP - 发布发车局 Request VO") +@Data +public class AppRidePlanCreateReqVO { + + @Schema(description = "路线名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "南昆山经典环线") + @NotBlank(message = "路线名称不能为空") + @Size(max = 100, message = "路线名称长度不能超过 100") + private String routeName; + + @Schema(description = "集合地点", requiredMode = Schema.RequiredMode.REQUIRED, example = "增城广场加油站") + @NotBlank(message = "集合地点不能为空") + @Size(max = 200, message = "集合地点长度不能超过 200") + private String meetPlace; + + @Schema(description = "集合时间", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "集合时间不能为空") + private LocalDateTime meetTime; + + @Schema(description = "全程约多少公里", example = "50") + @DecimalMin(value = "0.0", message = "公里数不能为负数") + private BigDecimal distanceKm; + + @Schema(description = "城市(同城过滤)", example = "广州") + @Size(max = 64, message = "城市长度不能超过 64") + private String city; + + @Schema(description = "标签名列表", example = "[\"跑山\",\"新手友好\"]") + @Size(max = 5, message = "最多选择 5 个标签") + private List tags; + + @Schema(description = "人数上限", example = "10") + @NotNull(message = "人数上限不能为空") + @Min(value = 2, message = "人数上限最少 2 人") + @Max(value = 100, message = "人数上限最多 100 人") + private Integer capacity; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRidePlanPageReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRidePlanPageReqVO.java new file mode 100644 index 0000000..92b33a8 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRidePlanPageReqVO.java @@ -0,0 +1,27 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.validation.constraints.Size; + +@Schema(description = "用户 APP - 发车局分页查询 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class AppRidePlanPageReqVO extends PageParam { + + @Schema(description = "城市(同城过滤)", example = "广州") + @Size(max = 64, message = "城市长度不能超过 64") + private String city; + + @Schema(description = "关键词,匹配路线名称", example = "南昆山") + @Size(max = 100, message = "关键词长度不能超过 100") + private String keyword; + + @Schema(description = "标签名,精确匹配", example = "跑山") + @Size(max = 32, message = "标签长度不能超过 32") + private String tag; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRidePlanRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRidePlanRespVO.java new file mode 100644 index 0000000..88fe975 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRidePlanRespVO.java @@ -0,0 +1,76 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "用户 APP - 发车局 Response VO(免登录可查,已做字段脱敏)") +@Data +public class AppRidePlanRespVO { + + @Schema(description = "发车局编号", example = "1") + private Long id; + + @Schema(description = "路线名称", example = "南昆山经典环线") + private String routeName; + + @Schema(description = "集合地点", example = "增城广场加油站") + private String meetPlace; + + @Schema(description = "集合时间") + private LocalDateTime meetTime; + + @Schema(description = "全程约多少公里", example = "50") + private BigDecimal distanceKm; + + @Schema(description = "城市", example = "广州") + private String city; + + @Schema(description = "标签名列表", example = "[\"跑山\",\"新手友好\"]") + private List tags; + + @Schema(description = "人数上限", example = "10") + private Integer capacity; + + @Schema(description = "已报名人数", example = "3") + private Integer joinCount; + + // —— 发起人(只暴露昵称/头像,不含手机号等敏感字段)—— + + @Schema(description = "发起人编号", example = "1024") + private Long hostUserId; + + @Schema(description = "发起人昵称", example = "山路信天游") + private String hostNickname; + + @Schema(description = "发起人头像") + private String hostAvatar; + + @Schema(description = "已报名骑手列表(昵称 + 头像)") + private List joinedUsers; + + @Schema(description = "当前登录用户是否已加入(游客恒为 false)") + private Boolean joinedByMe; + + @Schema(description = "创建时间") + private LocalDateTime createTime; + + @Schema(description = "已报名骑手(只暴露昵称 + 头像)") + @Data + public static class JoinUser { + + @Schema(description = "用户编号", example = "2048") + private Long userId; + + @Schema(description = "昵称", example = "压弯不压车") + private String nickname; + + @Schema(description = "头像") + private String avatar; + + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordCreateReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordCreateReqVO.java new file mode 100644 index 0000000..d05c3f5 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordCreateReqVO.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.DecimalMax; +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import java.math.BigDecimal; + +@Schema(description = "用户 APP - 骑行战报上报 Request VO(完骑后提交,需登录)") +@Data +public class AppRideRecordCreateReqVO { + + @Schema(description = "战报标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "南昆山 63 连弯,今天左弯终于不虚了") + @NotBlank(message = "战报标题不能为空") + @Size(max = 100, message = "战报标题不能超过 100 字") + private String title; + + @Schema(description = "路线名(可选)", example = "南昆山") + @Size(max = 50, message = "路线名不能超过 50 字") + private String roadName; + + @Schema(description = "弯道名(可选,有值参与天梯榜)", example = "观音潭右弯") + @Size(max = 50, message = "弯道名不能超过 50 字") + private String cornerName; + + @Schema(description = "里程(公里)", requiredMode = Schema.RequiredMode.REQUIRED, example = "87.3") + @NotNull(message = "里程不能为空") + @DecimalMin(value = "0.1", message = "里程至少 0.1 公里") + @DecimalMax(value = "5000", message = "里程异常") + private BigDecimal distanceKm; + + @Schema(description = "骑行时长(秒)", requiredMode = Schema.RequiredMode.REQUIRED, example = "5760") + @NotNull(message = "骑行时长不能为空") + @Min(value = 30, message = "骑行时长太短") + private Integer durationSec; + + @Schema(description = "极速(km/h)", requiredMode = Schema.RequiredMode.REQUIRED, example = "138") + @NotNull(message = "极速不能为空") + @DecimalMin(value = "0", message = "极速不能为负") + @DecimalMax(value = "500", message = "极速异常") + private BigDecimal topSpeedKmh; + + @Schema(description = "过弯次数", requiredMode = Schema.RequiredMode.REQUIRED, example = "63") + @NotNull(message = "过弯次数不能为空") + @Min(value = 0, message = "过弯次数不能为负") + @Max(value = 10000, message = "过弯次数异常") + private Integer curveCount; + + @Schema(description = "最大压弯角度(度)", requiredMode = Schema.RequiredMode.REQUIRED, example = "47.2") + @NotNull(message = "最大压弯角度不能为空") + @DecimalMin(value = "0", message = "压弯角度不能为负") + @DecimalMax(value = "90", message = "压弯角度异常") + private BigDecimal maxLean; + + @Schema(description = "轨迹点 JSON:[{\"lat\":23.1,\"lng\":113.3,\"speed\":88}],可选,仅详情接口回显") + @Size(max = 2_000_000, message = "轨迹数据过大") + private String trackJson; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordPageReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordPageReqVO.java new file mode 100644 index 0000000..bfd3eda --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordPageReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.validation.constraints.Size; + +@Schema(description = "用户 APP - 骑行战报分页查询 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class AppRideRecordPageReqVO extends PageParam { + + @Schema(description = "按骑手过滤(查某人战报)", example = "1024") + private Long userId; + + @Schema(description = "路线名过滤", example = "南昆山") + @Size(max = 50, message = "路线名长度不能超过 50") + private String roadName; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordRespVO.java new file mode 100644 index 0000000..ffb5ed1 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordRespVO.java @@ -0,0 +1,115 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "用户 APP - 骑行战报 Response VO(列表不含轨迹,详情含)") +@Data +public class AppRideRecordRespVO { + + @Schema(description = "战报编号", example = "1") + private Long id; + + @Schema(description = "战报标题", example = "南昆山 63 连弯,今天左弯终于不虚了") + private String title; + + @Schema(description = "路线名", example = "南昆山") + private String roadName; + + @Schema(description = "弯道名", example = "观音潭右弯") + private String cornerName; + + @Schema(description = "里程(公里)", example = "87.3") + private BigDecimal distanceKm; + + @Schema(description = "骑行时长(秒)", example = "5760") + private Integer durationSec; + + @Schema(description = "极速(km/h)", example = "138") + private BigDecimal topSpeedKmh; + + @Schema(description = "过弯次数", example = "63") + private Integer curveCount; + + @Schema(description = "最大压弯角度(度)", example = "47.2") + private BigDecimal maxLean; + + @Schema(description = "轨迹点 JSON,仅详情接口返回") + private String trackJson; + + @Schema(description = "点赞数", example = "128") + private Integer likeCount; + + @Schema(description = "当前登录用户是否已点赞(游客恒为 false)") + private Boolean likedByMe; + + // —— 骑手(只暴露昵称/头像,不含手机号等敏感字段)—— + + @Schema(description = "骑手编号", example = "1024") + private Long userId; + + @Schema(description = "骑手昵称", example = "山路信天游") + private String nickname; + + @Schema(description = "骑手头像") + private String avatar; + + @Schema(description = "发布时间") + private LocalDateTime createTime; + + @Schema(description = "天梯榜条目(rank 接口专用)") + @Data + public static class RankItem { + + @Schema(description = "名次,从 1 开始", example = "1") + private Integer rank; + + @Schema(description = "战报编号", example = "5") + private Long recordId; + + @Schema(description = "路线名", example = "南昆山") + private String roadName; + + @Schema(description = "弯道名", example = "观音潭右弯") + private String cornerName; + + @Schema(description = "最大压弯角度(度)", example = "52.0") + private BigDecimal maxLean; + + @Schema(description = "极速(km/h)", example = "62") + private BigDecimal topSpeedKmh; + + @Schema(description = "骑手编号", example = "1024") + private Long userId; + + @Schema(description = "骑手昵称", example = "压弯不压车") + private String nickname; + + @Schema(description = "骑手头像") + private String avatar; + + @Schema(description = "骑行时间") + private LocalDateTime createTime; + + } + + @Schema(description = "弯道榜单(rank 接口返回,按弯道分组)") + @Data + public static class CornerBoard { + + @Schema(description = "路线名", example = "南昆山") + private String roadName; + + @Schema(description = "弯道名", example = "观音潭右弯") + private String cornerName; + + @Schema(description = "该弯道上榜条目(已按压弯角度降序)") + private List entries; + + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordStatsRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordStatsRespVO.java new file mode 100644 index 0000000..adfe44b --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideRecordStatsRespVO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 用户 APP - 骑手战报统计 Response VO + * + * 供首页骑友列表 / 骑友主页展示「战报数 / 最佳压弯 / 总里程」骑行数据行, + * 批量接口避免列表逐人查询。 + */ +@Schema(description = "用户 APP - 骑手战报统计 Response VO") +@Data +public class AppRideRecordStatsRespVO { + + @Schema(description = "骑手用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long userId; + + @Schema(description = "战报数量(正常状态)", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Long reportCount; + + @Schema(description = "最佳压弯角度(度)", requiredMode = Schema.RequiredMode.REQUIRED, example = "38.5") + private Double bestLean; + + @Schema(description = "总里程(公里)", requiredMode = Schema.RequiredMode.REQUIRED, example = "260.8") + private Double totalKm; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideTagRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideTagRespVO.java new file mode 100644 index 0000000..696f508 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideTagRespVO.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "用户 APP - 骑行标签 Response VO") +@Data +public class AppRideTagRespVO { + + @Schema(description = "标签编号", example = "1") + private Long id; + + @Schema(description = "标签名", example = "跑山党") + private String name; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideUserTagUpdateReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideUserTagUpdateReqVO.java new file mode 100644 index 0000000..168cda8 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/rideplan/vo/AppRideUserTagUpdateReqVO.java @@ -0,0 +1,17 @@ +package cn.iocoder.yudao.module.member.controller.app.rideplan.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import java.util.List; + +@Schema(description = "用户 APP - 设置我的骑行标签 Request VO") +@Data +public class AppRideUserTagUpdateReqVO { + + @Schema(description = "标签编号列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2]") + @NotEmpty(message = "标签编号列表不能为空") + private List tagIds; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/user/AppMemberUserController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/user/AppMemberUserController.java index bdab9dd..6996077 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/user/AppMemberUserController.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/user/AppMemberUserController.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.member.controller.app.user; import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWechatAuthReqVo; import cn.iocoder.yudao.module.member.service.comauth.ComAuthService; @@ -16,6 +17,8 @@ import cn.iocoder.yudao.module.member.service.level.MemberLevelService; import cn.iocoder.yudao.module.member.service.opentype.OpenTypeService; import cn.iocoder.yudao.module.member.service.user.MemberRecentTagService; import cn.iocoder.yudao.module.member.service.user.MemberUserService; +import cn.iocoder.yudao.module.system.api.oauth2.OAuth2TokenApi; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; @@ -28,8 +31,10 @@ import javax.validation.Valid; import java.util.List; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.AUTH_LOGIN_USER_DISABLED; @Tag(name = "用户 APP - 用户个人中心") @RestController @@ -50,11 +55,24 @@ public class AppMemberUserController { private ComAuthService comAuthService; @Resource private LocationService locationService; + @Resource + private OAuth2TokenApi oauth2TokenApi; @GetMapping("/get") @Operation(summary = "获得基本信息") public CommonResult getUserInfo() { MemberUserDO user = userService.getUser(getLoginUserId()); + // 禁用态已由 MemberUserDisableInterceptor 统一拦截;此处再兜底一次 + if (user == null || CommonStatusEnum.isDisable(user.getStatus())) { + if (user != null) { + try { + oauth2TokenApi.removeAccessTokenByUserId(user.getId(), UserTypeEnum.MEMBER.getValue()); + } catch (Exception e) { + log.warn("禁用用户拉资料时清 token 失败, userId={}", user.getId(), e); + } + } + throw exception(AUTH_LOGIN_USER_DISABLED); + } MemberLevelDO level = levelService.getLevel(user.getLevelId()); AppMemberUserInfoRespVO result = MemberUserConvert.INSTANCE.convert(user, level); // 获取用户tag diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/view/AppViewController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/view/AppViewController.java index 721ad69..1879c93 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/view/AppViewController.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/view/AppViewController.java @@ -82,11 +82,11 @@ public class AppViewController { put("search-action", "SQUARE_VIP_MALE"); put("label", "VIP男士"); }}); - menu.add(new HashMap() {{ - put("code", "5000"); - put("search-action", "SQUARE_FEMALE"); - put("label", "女生"); - }}); +// menu.add(new HashMap() {{ +// put("code", "5000"); +// put("search-action", "SQUARE_FEMALE"); +// put("label", "女生"); +// }}); } return success(menu); } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/airline/MemberAirlineAccountDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/airline/MemberAirlineAccountDO.java new file mode 100644 index 0000000..8c87ae1 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/airline/MemberAirlineAccountDO.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.airline; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 航空账号 DO + */ +@TableName("member_airline_account") +@KeySequence("member_airline_account_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberAirlineAccountDO extends TenantBaseDO { + + @TableId + private Long id; + /** 手机号(同一手机号可在不同渠道各有一个账号) */ + private String mobile; + /** 品牌渠道:CZ 南航 / MU 东航 / HU 海航 */ + private String channel; + /** 密码(BCrypt) */ + private String password; + /** 生效开始时间 */ + private LocalDateTime startTime; + /** 生效结束时间 */ + private LocalDateTime endTime; + /** + * 手机唯一识别号。首次登录绑定;客户端需保证重装/重启后不变(如 Android ANDROID_ID、iOS Keychain UUID)。 + */ + private String deviceId; + /** + * 状态:0 开启 1 关闭,见 {@link cn.iocoder.yudao.framework.common.enums.CommonStatusEnum} + */ + private Integer status; + /** 备注 */ + private String remark; + /** 关联会员用户编号 */ + private Long userId; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/airline/MemberAirlineFlightDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/airline/MemberAirlineFlightDO.java new file mode 100644 index 0000000..bed52f6 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/airline/MemberAirlineFlightDO.java @@ -0,0 +1,79 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.airline; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 航空 App 用户自建航线 DO + */ +@TableName("member_airline_flight") +@KeySequence("member_airline_flight_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberAirlineFlightDO extends TenantBaseDO { + + @TableId + private Long id; + /** 航空账号编号 member_airline_account.id */ + private Long accountId; + /** 航班号 */ + private String flightNo; + /** 任务/航班名称 */ + private String title; + /** 出发城市 */ + private String beginCity; + /** 出发机场 */ + private String beginAirport; + /** 到达城市 */ + private String endCity; + /** 到达机场 */ + private String endAirport; + /** 舱位/班次 */ + private String classes; + /** 状态 */ + private String status; + /** 计划起飞时间 */ + private String flyBeginDate0; + /** 计划到达时间 */ + private String flyEndDate0; + /** 实际起飞时间 */ + private String flyBeginDate1; + /** 实际到达时间 */ + private String flyEndDate1; + /** 签到时间 */ + private String signInTime; + /** 机号/编号 */ + private String aircraftNo; + /** 航司 */ + private String airline; + /** 机型 */ + private String aircraftType; + /** 登机口 */ + private String gate; + /** 出发机场停机位 */ + private String beginStand; + /** 到达机场停机位 */ + private String endStand; + /** 货关时间 */ + private String cargoClose; + /** 客关时间 */ + private String paxClose; + /** 离港时间 */ + private String leavePort; + /** 起飞时间 */ + private String takeOff; + /** 落地时间 */ + private String land; + /** 到港时间 */ + private String arrivePort; + /** 前序航班 */ + private String prevFlight; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/airline/MemberAirlinePointDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/airline/MemberAirlinePointDO.java new file mode 100644 index 0000000..f418d73 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/airline/MemberAirlinePointDO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.airline; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 航空 App 我的积点/钱包 DO(每账号一行) + */ +@TableName("member_airline_point") +@KeySequence("member_airline_point_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberAirlinePointDO extends TenantBaseDO { + + @TableId + private Long id; + /** 航空账号编号 member_airline_account.id */ + private Long accountId; + /** 当月飞行 */ + private String currentMonth; + /** 年度飞行 */ + private String currentYear; + /** 绩效积点 */ + private String jixiao; + /** 延误积点 */ + private String yanwu; + /** 当前积点 */ + private String currentPoint; + /** 钱包余额 */ + private String wallet; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/interaction/MemberInteractionDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/interaction/MemberInteractionDO.java new file mode 100644 index 0000000..0814fd4 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/interaction/MemberInteractionDO.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.interaction; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 会员互动消息 DO + */ +@TableName("member_interaction") +@KeySequence("member_interaction_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberInteractionDO extends TenantBaseDO { + + @TableId + private Long id; + + /** + * 接收人(被查看 / 被解锁) + */ + private Long receiverId; + + /** + * 操作人 + */ + private Long actorId; + + /** + * VIEWED / WECHAT_UNLOCKED + */ + private String bizType; + + /** + * 关联业务 id + */ + private Long refId; + + /** + * 是否已读 + */ + private Boolean readStatus; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/push/MemberPushDeviceDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/push/MemberPushDeviceDO.java new file mode 100644 index 0000000..913e578 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/push/MemberPushDeviceDO.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.push; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 会员推送设备 DO + */ +@TableName("member_push_device") +@KeySequence("member_push_device_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberPushDeviceDO extends TenantBaseDO { + + @TableId + private Long id; + + /** + * 会员编号 + */ + private Long memberId; + + /** + * 极光 RegistrationID + */ + private String registrationId; + + /** + * 极光别名 + */ + private String alias; + + /** + * 平台:ios / android + */ + private String platform; + + /** + * 推送服务商:jpush + */ + private String provider; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRidePlanDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRidePlanDO.java new file mode 100644 index 0000000..210275d --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRidePlanDO.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.rideplan; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 骑遇发车局 DO + */ +@TableName("member_ride_plan") +@KeySequence("member_ride_plan_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberRidePlanDO extends TenantBaseDO { + + @TableId + private Long id; + /** 发起人编号 member_user.id */ + private Long userId; + /** 路线名称 */ + private String routeName; + /** 集合地点 */ + private String meetPlace; + /** 集合时间 */ + private LocalDateTime meetTime; + /** 全程约多少公里 */ + private BigDecimal distanceKm; + /** 城市(同城过滤) */ + private String city; + /** 标签名,逗号分隔 */ + private String tags; + /** 人数上限 */ + private Integer capacity; + /** 已报名人数(冗余计数) */ + private Integer joinCount; + /** 被举报次数 */ + private Integer reportCount; + /** + * 状态:0 正常 1 已取消/下架 + */ + private Integer status; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRidePlanJoinDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRidePlanJoinDO.java new file mode 100644 index 0000000..17d1a8a --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRidePlanJoinDO.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.rideplan; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 骑遇发车局报名 DO + */ +@TableName("member_ride_plan_join") +@KeySequence("member_ride_plan_join_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberRidePlanJoinDO extends TenantBaseDO { + + @TableId + private Long id; + /** 发车局编号 member_ride_plan.id */ + private Long planId; + /** 报名人编号 member_user.id */ + private Long userId; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideRecordDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideRecordDO.java new file mode 100644 index 0000000..4e787d5 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideRecordDO.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.rideplan; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.math.BigDecimal; + +/** + * 骑遇骑行记录(战报)DO + */ +@TableName("member_ride_record") +@KeySequence("member_ride_record_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberRideRecordDO extends TenantBaseDO { + + @TableId + private Long id; + /** 骑手编号 member_user.id */ + private Long userId; + /** 战报标题 */ + private String title; + /** 路线名(如:南昆山) */ + private String roadName; + /** 弯道名(如:观音潭右弯),为空表示非跑山记录 */ + private String cornerName; + /** 里程(公里) */ + private BigDecimal distanceKm; + /** 骑行时长(秒) */ + private Integer durationSec; + /** 极速(km/h) */ + private BigDecimal topSpeedKmh; + /** 过弯次数 */ + private Integer curveCount; + /** 最大压弯角度(度) */ + private BigDecimal maxLean; + /** 轨迹点 JSON([{"lat":..,"lng":..,"speed":..}]),仅详情接口返回 */ + private String trackJson; + /** 点赞数(冗余计数) */ + private Integer likeCount; + /** 被举报次数 */ + private Integer reportCount; + /** + * 状态:0 正常 1 已下架 + */ + private Integer status; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideRecordLikeDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideRecordLikeDO.java new file mode 100644 index 0000000..edb1192 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideRecordLikeDO.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.rideplan; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 骑行战报点赞 DO(一条记录一个用户只能赞一次,重复点赞视为取消) + */ +@TableName("member_ride_record_like") +@KeySequence("member_ride_record_like_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberRideRecordLikeDO extends TenantBaseDO { + + @TableId + private Long id; + /** 战报编号 member_ride_record.id */ + private Long recordId; + /** 点赞人编号 member_user.id */ + private Long userId; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideTagDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideTagDO.java new file mode 100644 index 0000000..bff2a21 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideTagDO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.rideplan; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 骑遇骑行标签字典 DO(跑山党 / 新手友好 / 摩旅组队 ...) + */ +@TableName("member_ride_tag") +@KeySequence("member_ride_tag_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberRideTagDO extends TenantBaseDO { + + @TableId + private Long id; + /** 标签名 */ + private String name; + /** 显示顺序(越小越靠前) */ + private Integer sort; + /** + * 状态:0 启用 1 停用,见 {@link cn.iocoder.yudao.framework.common.enums.CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideUserTagDO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideUserTagDO.java new file mode 100644 index 0000000..38972a2 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/dataobject/rideplan/MemberRideUserTagDO.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.member.dal.dataobject.rideplan; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 骑手个人骑行标签关联 DO(Rider.tags) + */ +@TableName("member_ride_user_tag") +@KeySequence("member_ride_user_tag_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberRideUserTagDO extends TenantBaseDO { + + @TableId + private Long id; + /** 用户编号 member_user.id */ + private Long userId; + /** 标签编号 member_ride_tag.id */ + private Long tagId; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/airline/MemberAirlineAccountMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/airline/MemberAirlineAccountMapper.java new file mode 100644 index 0000000..d08585a --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/airline/MemberAirlineAccountMapper.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.member.dal.mysql.airline; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.member.controller.admin.airline.vo.MemberAirlineAccountPageReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlineAccountDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface MemberAirlineAccountMapper extends BaseMapperX { + + default PageResult selectPage(MemberAirlineAccountPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(MemberAirlineAccountDO::getMobile, reqVO.getMobile()) + .eqIfPresent(MemberAirlineAccountDO::getChannel, reqVO.getChannel()) + .eqIfPresent(MemberAirlineAccountDO::getStatus, reqVO.getStatus()) + .likeIfPresent(MemberAirlineAccountDO::getDeviceId, reqVO.getDeviceId()) + .betweenIfPresent(MemberAirlineAccountDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(MemberAirlineAccountDO::getId)); + } + + default MemberAirlineAccountDO selectByMobileAndChannel(String mobile, String channel) { + return selectOne(new LambdaQueryWrapperX() + .eq(MemberAirlineAccountDO::getMobile, mobile) + .eq(MemberAirlineAccountDO::getChannel, channel)); + } + + default MemberAirlineAccountDO selectByDeviceIdAndChannel(String deviceId, String channel) { + return selectOne(new LambdaQueryWrapperX() + .eq(MemberAirlineAccountDO::getDeviceId, deviceId) + .eq(MemberAirlineAccountDO::getChannel, channel)); + } + + default List selectListByDeviceId(String deviceId) { + return selectList(new LambdaQueryWrapperX() + .eq(MemberAirlineAccountDO::getDeviceId, deviceId)); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/airline/MemberAirlineFlightMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/airline/MemberAirlineFlightMapper.java new file mode 100644 index 0000000..4baceef --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/airline/MemberAirlineFlightMapper.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.member.dal.mysql.airline; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlineFlightDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface MemberAirlineFlightMapper extends BaseMapperX { + + default List selectListByAccountId(Long accountId) { + return selectList(new LambdaQueryWrapperX() + .eq(MemberAirlineFlightDO::getAccountId, accountId) + .orderByDesc(MemberAirlineFlightDO::getId)); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/airline/MemberAirlinePointMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/airline/MemberAirlinePointMapper.java new file mode 100644 index 0000000..89d0313 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/airline/MemberAirlinePointMapper.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.member.dal.mysql.airline; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlinePointDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface MemberAirlinePointMapper extends BaseMapperX { + + default MemberAirlinePointDO selectByAccountId(Long accountId) { + return selectOne(new LambdaQueryWrapperX() + .eq(MemberAirlinePointDO::getAccountId, accountId)); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/interaction/MemberInteractionMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/interaction/MemberInteractionMapper.java new file mode 100644 index 0000000..fd81b16 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/interaction/MemberInteractionMapper.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.member.dal.mysql.interaction; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.interaction.MemberInteractionDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface MemberInteractionMapper extends BaseMapperX { + + default PageResult selectPageByReceiver(Long receiverId, PageParam pageParam) { + return selectPage(pageParam, new LambdaQueryWrapperX() + .eq(MemberInteractionDO::getReceiverId, receiverId) + .orderByDesc(MemberInteractionDO::getId)); + } + + default Long selectUnreadCount(Long receiverId) { + return selectCount(new LambdaQueryWrapperX() + .eq(MemberInteractionDO::getReceiverId, receiverId) + .eq(MemberInteractionDO::getReadStatus, false)); + } + + default MemberInteractionDO selectLatest(Long receiverId) { + List list = selectList(new LambdaQueryWrapperX() + .eq(MemberInteractionDO::getReceiverId, receiverId) + .orderByDesc(MemberInteractionDO::getId) + .last("LIMIT 1")); + return list.isEmpty() ? null : list.get(0); + } + + default int markAllRead(Long receiverId) { + return update(null, new LambdaUpdateWrapper() + .set(MemberInteractionDO::getReadStatus, true) + .eq(MemberInteractionDO::getReceiverId, receiverId) + .eq(MemberInteractionDO::getReadStatus, false)); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/push/MemberPushDeviceMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/push/MemberPushDeviceMapper.java new file mode 100644 index 0000000..d05b76c --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/push/MemberPushDeviceMapper.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.member.dal.mysql.push; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushDeviceDO; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface MemberPushDeviceMapper extends BaseMapperX { + + default MemberPushDeviceDO selectByRegistrationId(String registrationId) { + return selectOne(MemberPushDeviceDO::getRegistrationId, registrationId); + } + + default List selectListByMemberId(Long memberId) { + return selectList(new LambdaQueryWrapperX() + .eq(MemberPushDeviceDO::getMemberId, memberId) + .orderByDesc(MemberPushDeviceDO::getId)); + } + + /** + * 物理删除,避免逻辑删除后 registration_id 唯一索引冲突。 + */ + @Delete("DELETE FROM member_push_device WHERE id = #{id}") + int physicalDeleteById(@Param("id") Long id); + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRidePlanJoinMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRidePlanJoinMapper.java new file mode 100644 index 0000000..a573386 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRidePlanJoinMapper.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.member.dal.mysql.rideplan; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRidePlanJoinDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface MemberRidePlanJoinMapper extends BaseMapperX { + + default List selectListByPlanId(Long planId) { + return selectList(new LambdaQueryWrapperX() + .eq(MemberRidePlanJoinDO::getPlanId, planId)); + } + + default MemberRidePlanJoinDO selectByPlanIdAndUserId(Long planId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(MemberRidePlanJoinDO::getPlanId, planId) + .eq(MemberRidePlanJoinDO::getUserId, userId)); + } + + default List selectListByUserId(Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(MemberRidePlanJoinDO::getUserId, userId)); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRidePlanMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRidePlanMapper.java new file mode 100644 index 0000000..949afef --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRidePlanMapper.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.member.dal.mysql.rideplan; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanPageReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRidePlanDO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDateTime; + +@Mapper +public interface MemberRidePlanMapper extends BaseMapperX { + + /** + * 发车局分页(免登录可查):只查未下架、未过期的,按集合时间升序 + */ + default PageResult selectPage(AppRidePlanPageReqVO reqVO) { + // 注意:LambdaQueryWrapperX 的 ifPresent 系列须在原生 eq/ge 之前调用, + // 原生方法返回值会退化为 LambdaQueryWrapper,链式后续找不到 ifPresent + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .eqIfPresent(MemberRidePlanDO::getCity, reqVO.getCity()) + .likeIfPresent(MemberRidePlanDO::getRouteName, reqVO.getKeyword()); + wrapper.eq(MemberRidePlanDO::getStatus, 0); + wrapper.ge(MemberRidePlanDO::getMeetTime, LocalDateTime.now()); + if (reqVO.getTag() != null && !reqVO.getTag().isEmpty()) { + wrapper.apply("FIND_IN_SET({0}, tags) > 0", reqVO.getTag()); + } + wrapper.orderByAsc(MemberRidePlanDO::getMeetTime); + return selectPage(reqVO, wrapper); + } + + default MemberRidePlanDO selectByIdAndStatus(Long id, Integer status) { + return selectOne(new LambdaQueryWrapperX() + .eq(MemberRidePlanDO::getId, id) + .eq(MemberRidePlanDO::getStatus, status)); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideRecordLikeMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideRecordLikeMapper.java new file mode 100644 index 0000000..65a82b2 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideRecordLikeMapper.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.member.dal.mysql.rideplan; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideRecordLikeDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface MemberRideRecordLikeMapper extends BaseMapperX { + + default MemberRideRecordLikeDO selectByRecordIdAndUserId(Long recordId, Long userId) { + return selectOne(new LambdaQueryWrapperX() + .eq(MemberRideRecordLikeDO::getRecordId, recordId) + .eq(MemberRideRecordLikeDO::getUserId, userId)); + } + + default List selectListByUserIdAndRecordIds(Long userId, Collection recordIds) { + return selectList(new LambdaQueryWrapperX() + .eq(MemberRideRecordLikeDO::getUserId, userId) + .in(MemberRideRecordLikeDO::getRecordId, recordIds)); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideRecordMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideRecordMapper.java new file mode 100644 index 0000000..6c29ff0 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideRecordMapper.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.member.dal.mysql.rideplan; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordPageReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideRecordDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +@Mapper +public interface MemberRideRecordMapper extends BaseMapperX { + + /** + * 战报墙分页(免登录可查):只查正常状态,按发布时间倒序 + */ + default PageResult selectPage(AppRideRecordPageReqVO reqVO) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .eqIfPresent(MemberRideRecordDO::getUserId, reqVO.getUserId()) + .eqIfPresent(MemberRideRecordDO::getRoadName, reqVO.getRoadName()); + wrapper.eq(MemberRideRecordDO::getStatus, 0); + wrapper.orderByDesc(MemberRideRecordDO::getId); + return selectPage(reqVO, wrapper); + } + + /** + * 我的历史骑行(分页) + */ + default PageResult selectMyPage(Long userId, AppRideRecordPageReqVO reqVO) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .eq(MemberRideRecordDO::getUserId, userId) + .eq(MemberRideRecordDO::getStatus, 0); + wrapper.orderByDesc(MemberRideRecordDO::getId); + return selectPage(reqVO, wrapper); + } + + /** + * 弯道天梯榜:取最大压弯角度最高的前 N 条(有弯道名的跑山记录) + */ + default List selectRank(String roadName, String cornerName, int limit) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .eqIfPresent(MemberRideRecordDO::getRoadName, roadName) + .eqIfPresent(MemberRideRecordDO::getCornerName, cornerName); + wrapper.eq(MemberRideRecordDO::getStatus, 0); + wrapper.isNotNull(MemberRideRecordDO::getCornerName); + wrapper.ne(MemberRideRecordDO::getCornerName, ""); + wrapper.orderByDesc(MemberRideRecordDO::getMaxLean); + wrapper.last("LIMIT " + limit); + return selectList(wrapper); + } + + default MemberRideRecordDO selectByIdAndStatus(Long id, Integer status) { + return selectOne(new LambdaQueryWrapperX() + .eq(MemberRideRecordDO::getId, id) + .eq(MemberRideRecordDO::getStatus, status)); + } + + /** + * 批量查一批骑手的正常状态战报(用于列表/主页骑行数据统计) + */ + default List selectNormalByUserIds(Collection userIds) { + return selectList(new LambdaQueryWrapperX() + .in(MemberRideRecordDO::getUserId, userIds) + .eq(MemberRideRecordDO::getStatus, 0)); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideTagMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideTagMapper.java new file mode 100644 index 0000000..a8b8e05 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideTagMapper.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.member.dal.mysql.rideplan; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideTagDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface MemberRideTagMapper extends BaseMapperX { + + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(MemberRideTagDO::getStatus, status) + .orderByAsc(MemberRideTagDO::getSort)); + } + + default MemberRideTagDO selectByName(String name) { + return selectOne(new LambdaQueryWrapperX() + .eq(MemberRideTagDO::getName, name)); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideUserTagMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideUserTagMapper.java new file mode 100644 index 0000000..940e5e6 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/rideplan/MemberRideUserTagMapper.java @@ -0,0 +1,29 @@ +package cn.iocoder.yudao.module.member.dal.mysql.rideplan; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideUserTagDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +@Mapper +public interface MemberRideUserTagMapper extends BaseMapperX { + + default List selectListByUserId(Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(MemberRideUserTagDO::getUserId, userId)); + } + + default void deleteByUserIdAndTagId(Long userId, Long tagId) { + delete(new LambdaQueryWrapperX() + .eq(MemberRideUserTagDO::getUserId, userId) + .eq(MemberRideUserTagDO::getTagId, tagId)); + } + + default void deleteByUserId(Long userId) { + delete(new LambdaQueryWrapperX() + .eq(MemberRideUserTagDO::getUserId, userId)); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java index 812593a..c8366c1 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/dal/mysql/user/MemberUserMapper.java @@ -27,6 +27,24 @@ public interface MemberUserMapper extends BaseMapperX { return selectOne(MemberUserDO::getMobile, mobile); } + /** + * 按微信号查其他用户(用于唯一性校验,排除指定会员) + */ +// default MemberUserDO selectByWechatNumExcludeId(String wechatNum, Long excludeMemberId) { +// return selectOne(new LambdaQueryWrapperX() +// .eq(MemberUserDO::getWechatNum, wechatNum) +// .neIfPresent(MemberUserDO::getId, excludeMemberId)); +// } + + /** + * 按微信号查其他用户(用于唯一性校验,排除本人) + */ + default MemberUserDO selectByWechatNumExcludeId(String wechatNum, Long excludeMemberId) { + return selectOne(new LambdaQueryWrapperX() + .eq(MemberUserDO::getWechatNum, wechatNum) + .ne(excludeMemberId != null, MemberUserDO::getId, excludeMemberId)); + } + default List selectIdListByEmptyUserCode(int limit) { return selectList(new LambdaQueryWrapperX() .select(MemberUserDO::getId) @@ -74,6 +92,8 @@ public interface MemberUserMapper extends BaseMapperX { .betweenIfPresent(MemberUserDO::getCreateTime, reqVO.getCreateTime()) .eqIfPresent(MemberUserDO::getLevelId, reqVO.getLevelId()) .eqIfPresent(MemberUserDO::getGroupId, reqVO.getGroupId()) + .eqIfPresent(MemberUserDO::getUserCode, reqVO.getUserCode()) + .eqIfPresent(MemberUserDO::getSex, reqVO.getSex()) .apply(StrUtil.isNotEmpty(tagIdSql), tagIdSql) .orderByDesc(MemberUserDO::getId)); } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/crypto/RespBodyEncryptConfiguration.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/crypto/RespBodyEncryptConfiguration.java new file mode 100644 index 0000000..a4de7ef --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/crypto/RespBodyEncryptConfiguration.java @@ -0,0 +1,9 @@ +package cn.iocoder.yudao.module.member.framework.crypto; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(RespBodyEncryptProperties.class) +public class RespBodyEncryptConfiguration { +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/crypto/RespBodyEncryptProperties.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/crypto/RespBodyEncryptProperties.java new file mode 100644 index 0000000..733f5b9 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/crypto/RespBodyEncryptProperties.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.member.framework.crypto; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * App 敏感接口响应体加密配置 + */ +@ConfigurationProperties(prefix = "yudao.member.resp-encrypt") +@Data +public class RespBodyEncryptProperties { + + /** + * 是否开启(总开关) + */ + private boolean enabled = true; + + /** + * RSA 私钥(PKCS#8 Base64,无 PEM 头尾) + */ + private String privateKeyBase64; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/crypto/RespBodyEncryptResponseAdvice.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/crypto/RespBodyEncryptResponseAdvice.java new file mode 100644 index 0000000..ce34f85 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/crypto/RespBodyEncryptResponseAdvice.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.member.framework.crypto; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.MethodParameter; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +import javax.annotation.PostConstruct; +import java.util.List; + +/** + * 对白名单接口的 CommonResult.data 强制做响应加密(不依赖客户端是否声明 X-Resp-Enc)。 + */ +@ControllerAdvice +@Order(Ordered.HIGHEST_PRECEDENCE) +@Slf4j +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "yudao.member.resp-encrypt", name = "enabled", havingValue = "true", matchIfMissing = true) +public class RespBodyEncryptResponseAdvice implements ResponseBodyAdvice { + + private final RespBodyEncryptProperties properties; + private final ObjectMapper objectMapper; + + private RespBodyEncryptSupport support; + + @PostConstruct + public void init() { + if (StrUtil.isBlank(properties.getPrivateKeyBase64())) { + log.warn("[resp-encrypt] 未配置 yudao.member.resp-encrypt.private-key-base64,响应加密不可用"); + support = null; + return; + } + support = new RespBodyEncryptSupport(properties.getPrivateKeyBase64(), objectMapper); + log.info("[resp-encrypt] 响应加密已启用(强制),白名单={}", RespBodyEncryptSupport.PATH_SUFFIXES); + } + + @Override + public boolean supports(MethodParameter returnType, Class converterType) { + return support != null + && returnType.getMethod() != null + && returnType.getMethod().getReturnType() == CommonResult.class; + } + + @Override + @SuppressWarnings("unchecked") + public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, + Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { + if (!(body instanceof CommonResult) || support == null) { + return body; + } + CommonResult result = (CommonResult) body; + if (!result.isSuccess() || result.getData() == null) { + return body; + } + + String path = request.getURI().getPath(); + if (!RespBodyEncryptSupport.matchPath(path)) { + return body; + } + + List ekHeaders = request.getHeaders().get(RespBodyEncryptSupport.HEADER_RESP_EK); + String ek = (ekHeaders != null && !ekHeaders.isEmpty()) ? ekHeaders.get(0) : null; + try { + result.setData(support.encryptData(result.getData(), ek)); + } catch (Exception e) { + log.error("[resp-encrypt] 加密失败 path={} err={}", path, e.getMessage()); + return CommonResult.error(1_004_000_101, "响应加密失败,请升级客户端后重试"); + } + return result; + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/crypto/RespBodyEncryptSupport.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/crypto/RespBodyEncryptSupport.java new file mode 100644 index 0000000..f060656 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/crypto/RespBodyEncryptSupport.java @@ -0,0 +1,125 @@ +package cn.iocoder.yudao.module.member.framework.crypto; + +import cn.hutool.core.codec.Base64; +import cn.hutool.core.util.StrUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * 响应体加密:客户端可用 RSA 公钥加密随机 AES Key(请求 Header),服务端 AES-GCM 加密 data。 + * 若客户端未带 Key,服务端生成 AES Key 并写入响应信封字段 {@code ek}(RSA 私钥加密,客户端公钥解密)。 + */ +@Slf4j +public class RespBodyEncryptSupport { + + public static final String HEADER_RESP_ENC = "X-Resp-Enc"; + public static final String HEADER_RESP_EK = "X-Resp-Ek"; + + public static final Set PATH_SUFFIXES = new HashSet<>(Arrays.asList( + "/member/recent/quick-page", + "/member/recent/getById", + "/member/recent/look-wechat", + "/member/user/page" + )); + + private static final String RSA_TRANSFORMATION = "RSA/ECB/PKCS1Padding"; + private static final String AES_TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int GCM_TAG_BITS = 128; + private static final int AES_KEY_BITS = 256; + private static final int IV_LEN = 12; + + private final PrivateKey privateKey; + private final ObjectMapper objectMapper; + private final SecureRandom secureRandom = new SecureRandom(); + + public RespBodyEncryptSupport(String privateKeyBase64, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + try { + byte[] keyBytes = Base64.decode(privateKeyBase64); + this.privateKey = KeyFactory.getInstance("RSA") + .generatePrivate(new PKCS8EncodedKeySpec(keyBytes)); + } catch (Exception e) { + throw new IllegalStateException("初始化响应加密 RSA 私钥失败", e); + } + } + + public static boolean matchPath(String path) { + if (StrUtil.isBlank(path)) { + return false; + } + for (String suffix : PATH_SUFFIXES) { + if (path.endsWith(suffix)) { + return true; + } + } + return false; + } + + /** + * 白名单接口强制加密 data。优先使用请求头中的 AES Key;否则服务端生成 Key 并写入信封 {@code ek}。 + */ + public Map encryptData(Object data, String requestEkBase64) throws Exception { + final boolean serverGeneratedKey = StrUtil.isBlank(requestEkBase64); + final byte[] aesKey = serverGeneratedKey + ? generateAesKeyBytes() + : decryptAesKey(requestEkBase64); + byte[] plain = objectMapper.writeValueAsBytes(data == null ? new HashMap<>() : data); + + byte[] iv = new byte[IV_LEN]; + secureRandom.nextBytes(iv); + + Cipher aes = Cipher.getInstance(AES_TRANSFORMATION); + aes.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(aesKey, "AES"), new GCMParameterSpec(GCM_TAG_BITS, iv)); + byte[] ct = aes.doFinal(plain); + + Map envelope = new HashMap<>(8); + envelope.put("enc", 1); + envelope.put("v", 1); + envelope.put("alg", "RSA-PKCS1-AES-256-GCM"); + envelope.put("iv", Base64.encode(iv)); + envelope.put("ct", Base64.encode(ct)); + if (serverGeneratedKey) { + envelope.put("ek", encryptAesKeyForClient(aesKey)); + } + return envelope; + } + + private String encryptAesKeyForClient(byte[] aesKey) throws Exception { + Cipher rsa = Cipher.getInstance(RSA_TRANSFORMATION); + rsa.init(Cipher.ENCRYPT_MODE, privateKey); + return Base64.encode(rsa.doFinal(aesKey)); + } + + private byte[] decryptAesKey(String ekBase64) throws Exception { + Cipher rsa = Cipher.getInstance(RSA_TRANSFORMATION); + rsa.init(Cipher.DECRYPT_MODE, privateKey); + byte[] key = rsa.doFinal(Base64.decode(ekBase64)); + if (key.length != 16 && key.length != 24 && key.length != 32) { + throw new IllegalArgumentException("AES Key 长度非法: " + key.length); + } + return key; + } + + /** 仅测试/工具用 */ + public static byte[] generateAesKeyBytes() throws Exception { + KeyGenerator kg = KeyGenerator.getInstance("AES"); + kg.init(AES_KEY_BITS); + SecretKey key = kg.generateKey(); + return key.getEncoded(); + } +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/config/JPushProperties.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/config/JPushProperties.java new file mode 100644 index 0000000..9299192 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/config/JPushProperties.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.member.framework.web.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties("jpush") +@Data +public class JPushProperties { + + private boolean enabled; + + private String appKey; + + private String masterSecret; + + private boolean apnsProduction; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/config/MemberWebConfiguration.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/config/MemberWebConfiguration.java index 82c7003..850b7f8 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/config/MemberWebConfiguration.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/config/MemberWebConfiguration.java @@ -1,9 +1,14 @@ package cn.iocoder.yudao.module.member.framework.web.config; import cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration; +import cn.iocoder.yudao.module.member.framework.web.core.MemberUserDisableInterceptor; import org.springdoc.core.GroupedOpenApi; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import javax.annotation.Resource; /** * member 模块的 web 组件的 Configuration @@ -11,7 +16,10 @@ import org.springframework.context.annotation.Configuration; * @author 芋道源码 */ @Configuration(proxyBeanMethods = false) -public class MemberWebConfiguration { +public class MemberWebConfiguration implements WebMvcConfigurer { + + @Resource + private MemberUserDisableInterceptor memberUserDisableInterceptor; /** * member 模块的 API 分组 @@ -21,4 +29,10 @@ public class MemberWebConfiguration { return YudaoSwaggerAutoConfiguration.buildGroupedOpenApi("member"); } + @Override + public void addInterceptors(InterceptorRegistry registry) { + // App 已登录请求统一校验禁用态(与个人资料页踢登录同一错误码) + registry.addInterceptor(memberUserDisableInterceptor).addPathPatterns("/**"); + } + } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/core/MemberUserDisableInterceptor.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/core/MemberUserDisableInterceptor.java new file mode 100644 index 0000000..0958325 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/framework/web/core/MemberUserDisableInterceptor.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.member.framework.web.core; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.framework.web.config.WebProperties; +import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; +import cn.iocoder.yudao.module.member.service.user.MemberUserService; +import cn.iocoder.yudao.module.system.api.oauth2.OAuth2TokenApi; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.AUTH_LOGIN_USER_DISABLED; + +/** + * App 端已登录请求统一校验会员是否禁用。 + *

+ * 与 {@code GET /member/user/get} 行为一致:禁用则清 token 并返回 + * {@link cn.iocoder.yudao.module.member.enums.MemberErrorCode#AUTH_LOGIN_USER_DISABLED}, + * 供 Flutter ApiClient 全局踢登录。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MemberUserDisableInterceptor implements HandlerInterceptor { + + private final WebProperties webProperties; + private final MemberUserService memberUserService; + private final OAuth2TokenApi oauth2TokenApi; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String uri = request.getRequestURI(); + String contextPath = request.getContextPath(); + String path = contextPath == null || contextPath.isEmpty() + ? uri + : uri.substring(contextPath.length()); + // 仅 App API + if (!path.startsWith(webProperties.getAppApi().getPrefix())) { + return true; + } + + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if (loginUser == null || loginUser.getId() == null) { + return true; + } + // 仅会员 + if (!UserTypeEnum.MEMBER.getValue().equals(loginUser.getUserType())) { + return true; + } + + MemberUserDO user = memberUserService.getUser(loginUser.getId()); + if (user == null || CommonStatusEnum.isDisable(user.getStatus())) { + if (user != null) { + try { + oauth2TokenApi.removeAccessTokenByUserId( + user.getId(), UserTypeEnum.MEMBER.getValue()); + } catch (Exception e) { + log.warn("禁用用户请求时清 token 失败, userId={}", user.getId(), e); + } + } + throw exception(AUTH_LOGIN_USER_DISABLED); + } + return true; + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineAccountService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineAccountService.java new file mode 100644 index 0000000..d705ec6 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineAccountService.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.member.service.airline; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.controller.admin.airline.vo.MemberAirlineAccountCreateReqVO; +import cn.iocoder.yudao.module.member.controller.admin.airline.vo.MemberAirlineAccountPageReqVO; +import cn.iocoder.yudao.module.member.controller.admin.airline.vo.MemberAirlineAccountUpdateReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlineAccountDO; + +import javax.validation.Valid; + +/** + * 航空账号 Service + */ +public interface MemberAirlineAccountService { + + Long createAirlineAccount(@Valid MemberAirlineAccountCreateReqVO createReqVO); + + void updateAirlineAccount(@Valid MemberAirlineAccountUpdateReqVO updateReqVO); + + void deleteAirlineAccount(Long id); + + MemberAirlineAccountDO getAirlineAccount(Long id); + + PageResult getAirlineAccountPage(MemberAirlineAccountPageReqVO pageReqVO); + + /** 管理端解绑设备,允许换机重新登录绑定 */ + void unbindDevice(Long id); + + /** + * 登录校验:密码、有效期、设备绑定。 + * 手机号与设备号均按渠道(channel)隔离:同一手机号/同一设备可在 CZ/MU/HU 各绑定一个账号。 + * 首次登录绑定 deviceId;成功后返回账号(可能已写入 deviceId)。 + */ + MemberAirlineAccountDO authenticateAndBindDevice(String mobile, String password, String deviceId, String channel); + + /** + * 会话校验(免登录,兼容新旧客户端): + * ① Token 有效 → 按登录用户(航空账号编号)定位; + * ② Token 失效但传了 手机号+渠道 → 按账号表定位; + * ③ 都没有 → 仅按设备识别号兜底(设备绑定的任一有效账号即放行)。 + * 校验内容:账号存在、未禁用、在有效期内、设备绑定匹配。 + */ + MemberAirlineAccountDO validateAirlineSession(Long loginUserId, String mobile, String deviceId, String channel); + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineAccountServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineAccountServiceImpl.java new file mode 100644 index 0000000..29d9e10 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineAccountServiceImpl.java @@ -0,0 +1,215 @@ +package cn.iocoder.yudao.module.member.service.airline; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.member.controller.admin.airline.vo.MemberAirlineAccountCreateReqVO; +import cn.iocoder.yudao.module.member.controller.admin.airline.vo.MemberAirlineAccountPageReqVO; +import cn.iocoder.yudao.module.member.controller.admin.airline.vo.MemberAirlineAccountUpdateReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlineAccountDO; +import cn.iocoder.yudao.module.member.dal.mysql.airline.MemberAirlineAccountMapper; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.*; + +@Service +@Validated +public class MemberAirlineAccountServiceImpl implements MemberAirlineAccountService { + + @Resource + private MemberAirlineAccountMapper airlineAccountMapper; + @Resource + private PasswordEncoder passwordEncoder; + + @Override + public Long createAirlineAccount(MemberAirlineAccountCreateReqVO createReqVO) { + validateTimeRange(createReqVO.getStartTime(), createReqVO.getEndTime()); + validateMobileUnique(null, createReqVO.getMobile(), createReqVO.getChannel()); + + MemberAirlineAccountDO account = BeanUtils.toBean(createReqVO, MemberAirlineAccountDO.class); + account.setPassword(passwordEncoder.encode(createReqVO.getPassword())); + account.setDeviceId(null); + airlineAccountMapper.insert(account); + return account.getId(); + } + + @Override + public void updateAirlineAccount(MemberAirlineAccountUpdateReqVO updateReqVO) { + validateExists(updateReqVO.getId()); + validateTimeRange(updateReqVO.getStartTime(), updateReqVO.getEndTime()); + validateMobileUnique(updateReqVO.getId(), updateReqVO.getMobile(), updateReqVO.getChannel()); + + MemberAirlineAccountDO updateObj = BeanUtils.toBean(updateReqVO, MemberAirlineAccountDO.class); + if (StrUtil.isBlank(updateReqVO.getPassword())) { + updateObj.setPassword(null); // 不更新密码 + } else { + updateObj.setPassword(passwordEncoder.encode(updateReqVO.getPassword())); + } + // 不允许通过普通更新改 deviceId + updateObj.setDeviceId(null); + airlineAccountMapper.updateById(updateObj); + } + + @Override + public void deleteAirlineAccount(Long id) { + validateExists(id); + airlineAccountMapper.deleteById(id); + } + + @Override + public MemberAirlineAccountDO getAirlineAccount(Long id) { + return airlineAccountMapper.selectById(id); + } + + @Override + public PageResult getAirlineAccountPage(MemberAirlineAccountPageReqVO pageReqVO) { + return airlineAccountMapper.selectPage(pageReqVO); + } + + @Override + public void unbindDevice(Long id) { + validateExists(id); + airlineAccountMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(MemberAirlineAccountDO::getId, id) + .set(MemberAirlineAccountDO::getDeviceId, null)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public MemberAirlineAccountDO authenticateAndBindDevice(String mobile, String password, String deviceId, String channel) { + MemberAirlineAccountDO account = airlineAccountMapper.selectByMobileAndChannel(mobile, channel); + if (account == null) { + throw exception(AIRLINE_ACCOUNT_BAD_CREDENTIALS); + } + if (!passwordEncoder.matches(password, account.getPassword())) { + throw exception(AIRLINE_ACCOUNT_BAD_CREDENTIALS); + } + if (CommonStatusEnum.isDisable(account.getStatus())) { + throw exception(AIRLINE_ACCOUNT_DISABLED); + } + LocalDateTime now = LocalDateTime.now(); + if (now.isBefore(account.getStartTime())) { + throw exception(AIRLINE_ACCOUNT_NOT_STARTED); + } + if (now.isAfter(account.getEndTime())) { + throw exception(AIRLINE_ACCOUNT_EXPIRED); + } + + String boundDeviceId = account.getDeviceId(); + if (StrUtil.isBlank(boundDeviceId)) { + // 首次绑定:同渠道下设备不能已被其他账号占用(不同渠道互不影响) + MemberAirlineAccountDO byDevice = airlineAccountMapper.selectByDeviceIdAndChannel(deviceId, channel); + if (byDevice != null && !Objects.equals(byDevice.getId(), account.getId())) { + throw exception(AIRLINE_ACCOUNT_DEVICE_USED); + } + MemberAirlineAccountDO update = new MemberAirlineAccountDO(); + update.setId(account.getId()); + update.setDeviceId(deviceId); + airlineAccountMapper.updateById(update); + account.setDeviceId(deviceId); + } else if (!StrUtil.equals(boundDeviceId, deviceId)) { + throw exception(AIRLINE_ACCOUNT_DEVICE_BOUND); + } + return account; + } + + @Override + public MemberAirlineAccountDO validateAirlineSession(Long loginUserId, String mobile, String deviceId, String channel) { + // ① 手机号+渠道:精确匹配,直接认定是本人账号 + if (StrUtil.isNotBlank(mobile) && StrUtil.isNotBlank(channel)) { + MemberAirlineAccountDO account = airlineAccountMapper.selectByMobileAndChannel(mobile, channel); + if (account == null) { + throw exception(AIRLINE_ACCOUNT_NOT_EXISTS); + } + validateAccount(account, deviceId); + return account; + } + + // 收集候选账号:② Token 定位 + ③ 设备识别号定位(两者合并,逐个核实) + // 注意:旧 Token(member_user 同步方案时期签发)的 userId 是 member_user 编号, + // 与 member_airline_account 的 id 共用数字空间,selectById 可能撞号命中别人的账号, + // 因此候选必须经过"设备匹配"核实身份后,才能采信其状态。 + List candidates = new ArrayList<>(); + if (loginUserId != null) { + MemberAirlineAccountDO account = airlineAccountMapper.selectById(loginUserId); + if (account != null) { + candidates.add(account); + } + } + for (MemberAirlineAccountDO account : airlineAccountMapper.selectListByDeviceId(deviceId)) { + if (candidates.stream().noneMatch(c -> Objects.equals(c.getId(), account.getId()))) { + candidates.add(account); + } + } + if (candidates.isEmpty()) { + throw exception(AIRLINE_ACCOUNT_NOT_EXISTS); + } + + // 逐个候选核实:设备不匹配说明不是本机绑定的账号(如旧 Token 撞号),跳过换下一个; + // 设备匹配后才校验状态,此时报"禁用/到期"才是真实问题 + for (MemberAirlineAccountDO account : candidates) { + if (StrUtil.isBlank(account.getDeviceId()) || !StrUtil.equals(account.getDeviceId(), deviceId)) { + continue; + } + validateAccount(account, deviceId); + return account; + } + // 所有候选的绑定设备都与本机不符 + throw exception(AIRLINE_ACCOUNT_DEVICE_BOUND); + } + + private void validateAccount(MemberAirlineAccountDO account, String deviceId) { + if (CommonStatusEnum.isDisable(account.getStatus())) { + throw exception(AIRLINE_ACCOUNT_DISABLED); + } + LocalDateTime now = LocalDateTime.now(); + if (account.getStartTime() != null && now.isBefore(account.getStartTime())) { + throw exception(AIRLINE_ACCOUNT_NOT_STARTED); + } + if (account.getEndTime() != null && now.isAfter(account.getEndTime())) { + throw exception(AIRLINE_ACCOUNT_EXPIRED); + } + // 设备必须与登录时绑定的设备一致(未绑定/绑定其他手机都不放行) + if (StrUtil.isBlank(account.getDeviceId()) || !StrUtil.equals(account.getDeviceId(), deviceId)) { + throw exception(AIRLINE_ACCOUNT_DEVICE_BOUND); + } + } + + private MemberAirlineAccountDO validateExists(Long id) { + MemberAirlineAccountDO account = airlineAccountMapper.selectById(id); + if (account == null) { + throw exception(AIRLINE_ACCOUNT_NOT_EXISTS); + } + return account; + } + + private void validateMobileUnique(Long id, String mobile, String channel) { + // 同一手机号允许在不同渠道各建一个账号(CZ/MU/HU 互不干扰) + MemberAirlineAccountDO exist = airlineAccountMapper.selectByMobileAndChannel(mobile, channel); + if (exist == null) { + return; + } + if (id == null || !Objects.equals(exist.getId(), id)) { + throw exception(AIRLINE_ACCOUNT_MOBILE_EXISTS); + } + } + + private void validateTimeRange(LocalDateTime startTime, LocalDateTime endTime) { + if (startTime != null && endTime != null && !endTime.isAfter(startTime)) { + throw exception(AIRLINE_ACCOUNT_TIME_INVALID); + } + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineDataService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineDataService.java new file mode 100644 index 0000000..31f23fa --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineDataService.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.member.service.airline; + +import cn.iocoder.yudao.module.member.controller.app.airline.vo.AirlineFlightVO; +import cn.iocoder.yudao.module.member.controller.app.airline.vo.AirlinePointVO; + +import java.util.List; + +/** + * 航空 App 数据云同步(自建航线 / 我的积点钱包) + */ +public interface MemberAirlineDataService { + + /** 航线列表(按账号) */ + List getFlightList(String mobile, String deviceId, String channel); + + /** 新增航线,返回编号 */ + Long createFlight(String mobile, String deviceId, String channel, AirlineFlightVO vo); + + /** 更新航线(校验归属) */ + void updateFlight(String mobile, String deviceId, String channel, AirlineFlightVO vo); + + /** 删除航线(校验归属) */ + void deleteFlight(String mobile, String deviceId, String channel, Long id); + + /** 我的积点/钱包(不存在则创建默认行) */ + AirlinePointVO getPoint(String mobile, String deviceId, String channel); + + /** 保存我的积点/钱包 */ + void updatePoint(String mobile, String deviceId, String channel, AirlinePointVO vo); + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineDataServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineDataServiceImpl.java new file mode 100644 index 0000000..35bcfbd --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/airline/MemberAirlineDataServiceImpl.java @@ -0,0 +1,190 @@ +package cn.iocoder.yudao.module.member.service.airline; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.member.controller.app.airline.vo.AirlineFlightVO; +import cn.iocoder.yudao.module.member.controller.app.airline.vo.AirlinePointVO; +import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlineAccountDO; +import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlineFlightDO; +import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlinePointDO; +import cn.iocoder.yudao.module.member.dal.mysql.airline.MemberAirlineFlightMapper; +import cn.iocoder.yudao.module.member.dal.mysql.airline.MemberAirlinePointMapper; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.List; +import java.util.Objects; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.AIRLINE_ACCOUNT_NOT_EXISTS; + +/** + * 航空 App 数据云同步实现。 + * 身份定位:复用 validateAirlineSession(免 Token,手机号+渠道+设备识别号),并顺带校验账号状态与设备绑定。 + */ +@Service +@Validated +public class MemberAirlineDataServiceImpl implements MemberAirlineDataService { + + @Resource + private MemberAirlineAccountService airlineAccountService; + @Resource + private MemberAirlineFlightMapper flightMapper; + @Resource + private MemberAirlinePointMapper pointMapper; + + @Override + public List getFlightList(String mobile, String deviceId, String channel) { + MemberAirlineAccountDO account = resolveAccount(mobile, deviceId, channel); + return flightMapper.selectListByAccountId(account.getId()).stream() + .map(this::convertFlight).collect(java.util.stream.Collectors.toList()); + } + + @Override + public Long createFlight(String mobile, String deviceId, String channel, AirlineFlightVO vo) { + MemberAirlineAccountDO account = resolveAccount(mobile, deviceId, channel); + MemberAirlineFlightDO flight = convertFlight(vo); + flight.setId(null); + flight.setAccountId(account.getId()); + flightMapper.insert(flight); + return flight.getId(); + } + + @Override + public void updateFlight(String mobile, String deviceId, String channel, AirlineFlightVO vo) { + MemberAirlineAccountDO account = resolveAccount(mobile, deviceId, channel); + MemberAirlineFlightDO exist = flightMapper.selectById(vo.getId()); + // 不存在或不属于当前账号,一律按不存在处理(防越权) + if (exist == null || !Objects.equals(exist.getAccountId(), account.getId())) { + throw exception(AIRLINE_ACCOUNT_NOT_EXISTS); + } + MemberAirlineFlightDO update = convertFlight(vo); + update.setId(exist.getId()); + update.setAccountId(account.getId()); + flightMapper.updateById(update); + } + + @Override + public void deleteFlight(String mobile, String deviceId, String channel, Long id) { + MemberAirlineAccountDO account = resolveAccount(mobile, deviceId, channel); + MemberAirlineFlightDO exist = flightMapper.selectById(id); + if (exist == null || !Objects.equals(exist.getAccountId(), account.getId())) { + throw exception(AIRLINE_ACCOUNT_NOT_EXISTS); + } + flightMapper.deleteById(id); + } + + @Override + public AirlinePointVO getPoint(String mobile, String deviceId, String channel) { + MemberAirlineAccountDO account = resolveAccount(mobile, deviceId, channel); + MemberAirlinePointDO point = pointMapper.selectByAccountId(account.getId()); + if (point == null) { + // 首次查询创建默认行 + point = MemberAirlinePointDO.builder().accountId(account.getId()) + .currentMonth("00").currentYear("00").jixiao("00") + .yanwu("00").currentPoint("00").wallet("00").build(); + pointMapper.insert(point); + } + return convertPoint(point); + } + + @Override + public void updatePoint(String mobile, String deviceId, String channel, AirlinePointVO vo) { + MemberAirlineAccountDO account = resolveAccount(mobile, deviceId, channel); + MemberAirlinePointDO point = pointMapper.selectByAccountId(account.getId()); + if (point == null) { + point = MemberAirlinePointDO.builder().accountId(account.getId()).build(); + } + point.setCurrentMonth(StrUtil.nullToEmpty(vo.getCurrentMonth())); + point.setCurrentYear(StrUtil.nullToEmpty(vo.getCurrentYear())); + point.setJixiao(StrUtil.nullToEmpty(vo.getJixiao())); + point.setYanwu(StrUtil.nullToEmpty(vo.getYanwu())); + point.setCurrentPoint(StrUtil.nullToEmpty(vo.getCurrentPoint())); + point.setWallet(StrUtil.nullToEmpty(vo.getWallet())); + if (point.getId() == null) { + pointMapper.insert(point); + } else { + pointMapper.updateById(point); + } + } + + private MemberAirlineAccountDO resolveAccount(String mobile, String deviceId, String channel) { + // 传 null 跳过 Token 定位路径,走 手机号+渠道(客户端固定会传) + return airlineAccountService.validateAirlineSession(null, mobile, deviceId, channel); + } + + private AirlineFlightVO convertFlight(MemberAirlineFlightDO d) { + AirlineFlightVO vo = new AirlineFlightVO(); + vo.setId(d.getId()); + vo.setFlightNo(d.getFlightNo()); + vo.setTitle(d.getTitle()); + vo.setBeginCity(d.getBeginCity()); + vo.setBeginAirport(d.getBeginAirport()); + vo.setEndCity(d.getEndCity()); + vo.setEndAirport(d.getEndAirport()); + vo.setClasses(d.getClasses()); + vo.setStatus(d.getStatus()); + vo.setFlyBeginDate0(d.getFlyBeginDate0()); + vo.setFlyEndDate0(d.getFlyEndDate0()); + vo.setFlyBeginDate1(d.getFlyBeginDate1()); + vo.setFlyEndDate1(d.getFlyEndDate1()); + vo.setSignInTime(d.getSignInTime()); + vo.setAircraftNo(d.getAircraftNo()); + vo.setAirline(d.getAirline()); + vo.setAircraftType(d.getAircraftType()); + vo.setGate(d.getGate()); + vo.setBeginStand(d.getBeginStand()); + vo.setEndStand(d.getEndStand()); + vo.setCargoClose(d.getCargoClose()); + vo.setPaxClose(d.getPaxClose()); + vo.setLeavePort(d.getLeavePort()); + vo.setTakeOff(d.getTakeOff()); + vo.setLand(d.getLand()); + vo.setArrivePort(d.getArrivePort()); + vo.setPrevFlight(d.getPrevFlight()); + return vo; + } + + private MemberAirlineFlightDO convertFlight(AirlineFlightVO vo) { + MemberAirlineFlightDO d = new MemberAirlineFlightDO(); + d.setFlightNo(StrUtil.nullToEmpty(vo.getFlightNo())); + d.setTitle(StrUtil.nullToEmpty(vo.getTitle())); + d.setBeginCity(StrUtil.nullToEmpty(vo.getBeginCity())); + d.setBeginAirport(StrUtil.nullToEmpty(vo.getBeginAirport())); + d.setEndCity(StrUtil.nullToEmpty(vo.getEndCity())); + d.setEndAirport(StrUtil.nullToEmpty(vo.getEndAirport())); + d.setClasses(StrUtil.nullToEmpty(vo.getClasses())); + d.setStatus(StrUtil.nullToEmpty(vo.getStatus())); + d.setFlyBeginDate0(StrUtil.nullToEmpty(vo.getFlyBeginDate0())); + d.setFlyEndDate0(StrUtil.nullToEmpty(vo.getFlyEndDate0())); + d.setFlyBeginDate1(StrUtil.nullToEmpty(vo.getFlyBeginDate1())); + d.setFlyEndDate1(StrUtil.nullToEmpty(vo.getFlyEndDate1())); + d.setSignInTime(StrUtil.nullToEmpty(vo.getSignInTime())); + d.setAircraftNo(StrUtil.nullToEmpty(vo.getAircraftNo())); + d.setAirline(StrUtil.nullToEmpty(vo.getAirline())); + d.setAircraftType(StrUtil.nullToEmpty(vo.getAircraftType())); + d.setGate(StrUtil.nullToEmpty(vo.getGate())); + d.setBeginStand(StrUtil.nullToEmpty(vo.getBeginStand())); + d.setEndStand(StrUtil.nullToEmpty(vo.getEndStand())); + d.setCargoClose(StrUtil.nullToEmpty(vo.getCargoClose())); + d.setPaxClose(StrUtil.nullToEmpty(vo.getPaxClose())); + d.setLeavePort(StrUtil.nullToEmpty(vo.getLeavePort())); + d.setTakeOff(StrUtil.nullToEmpty(vo.getTakeOff())); + d.setLand(StrUtil.nullToEmpty(vo.getLand())); + d.setArrivePort(StrUtil.nullToEmpty(vo.getArrivePort())); + d.setPrevFlight(StrUtil.nullToEmpty(vo.getPrevFlight())); + return d; + } + + private AirlinePointVO convertPoint(MemberAirlinePointDO d) { + AirlinePointVO vo = new AirlinePointVO(); + vo.setCurrentMonth(d.getCurrentMonth()); + vo.setCurrentYear(d.getCurrentYear()); + vo.setJixiao(d.getJixiao()); + vo.setYanwu(d.getYanwu()); + vo.setCurrentPoint(d.getCurrentPoint()); + vo.setWallet(d.getWallet()); + return vo; + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthService.java index bf051be..16137fc 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthService.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthService.java @@ -109,4 +109,19 @@ public interface MemberAuthService { * @return 聊天令牌 */ AppAuthLoginRespVO.ChatToken reissueChatToken(Long userId); + + /** + * 航空账号登录:校验密码、有效期、设备唯一绑定,并签发会员 Token + */ + AppAuthLoginRespVO airlineLogin(@Valid AppAuthAirlineLoginReqVO reqVO); + + /** + * 航空账号会话校验(免登录,兼容新旧客户端): + * ① Token 有效 → 按登录用户定位账号校验; + * ② Token 失效但传了 手机号+渠道 → 按账号表定位校验; + * ③ 都没有 → 仅按设备识别号兜底(设备绑定的任一有效账号即放行)。 + * 校验内容:账号存在、未禁用、在有效期内、设备绑定匹配。 + */ + void airlineCheck(Long loginUserId, String mobile, String deviceId, String channel); + } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImpl.java index 5032724..c06220f 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/auth/MemberAuthServiceImpl.java @@ -16,6 +16,8 @@ import cn.iocoder.yudao.module.member.controller.app.auth.vo.*; import cn.iocoder.yudao.module.member.convert.auth.AuthConvert; import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; import cn.iocoder.yudao.module.member.dal.redis.auth.MemberAuthLoginCodeRedisDAO; +import cn.iocoder.yudao.module.member.dal.dataobject.airline.MemberAirlineAccountDO; +import cn.iocoder.yudao.module.member.service.airline.MemberAirlineAccountService; import cn.iocoder.yudao.module.member.service.user.MemberRecentCfgService; import cn.iocoder.yudao.module.member.service.user.MemberRecentTagService; import cn.iocoder.yudao.module.member.service.user.MemberUserService; @@ -57,6 +59,12 @@ import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.*; @Slf4j public class MemberAuthServiceImpl implements MemberAuthService { + /** + * 航空账号专用 OAuth2 客户端编号(system_oauth2_client 表需存在该记录, + * access/refresh token 有效期设置极长,App 登录一次即可长期使用) + */ + private static final String CLIENT_ID_AIRLINE = "airline"; + @Resource private MemberUserService userService; @Resource @@ -78,6 +86,8 @@ public class MemberAuthServiceImpl implements MemberAuthService { private ChatApi chatApi; @Resource private MemberAuthLoginCodeRedisDAO authLoginCodeRedisDAO; + @Resource + private MemberAirlineAccountService airlineAccountService; @Override public AppAuthLoginRespVO login(AppAuthLoginReqVO reqVO) { @@ -173,6 +183,11 @@ public class MemberAuthServiceImpl implements MemberAuthService { private AppAuthLoginRespVO createTokenAfterLoginSuccess(MemberUserDO user, String mobile, LoginLogTypeEnum logType, String openid) { + // 统一拦截:已禁用账号不可登录 / 注册后自动登录 + if (CommonStatusEnum.isDisable(user.getStatus())) { + createLoginLog(user.getId(), mobile, logType, LoginResultEnum.USER_DISABLED); + throw exception(AUTH_LOGIN_USER_DISABLED); + } // 插入登陆日志 createLoginLog(user.getId(), mobile, logType, LoginResultEnum.SUCCESS); // 创建 Token 令牌 @@ -241,7 +256,7 @@ public class MemberAuthServiceImpl implements MemberAuthService { public AppAuthLoginRespVO smsRegister(AppAuthRegisterRespVO reqVO) { String userIp = getClientIP(); // 校验验证码 - if (ObjectUtil.notEqual(reqVO.getCode(), "9999")) { + if (ObjectUtil.notEqual(reqVO.getCode(), "8855")) { smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.MEMBER_LOGIN.getScene(), userIp)); } @@ -339,6 +354,13 @@ public class MemberAuthServiceImpl implements MemberAuthService { public AppAuthLoginRespVO refreshToken(String refreshToken) { OAuth2AccessTokenRespDTO accessTokenDO = oauth2TokenApi.refreshAccessToken(refreshToken, OAuth2ClientConstants.CLIENT_ID_DEFAULT); + MemberUserDO user = userService.getUser(accessTokenDO.getUserId()); + if (user == null || CommonStatusEnum.isDisable(user.getStatus())) { + // 禁用后禁止续期,并清掉该用户全部 token + oauth2TokenApi.removeAccessTokenByUserId(accessTokenDO.getUserId(), getUserType().getValue()); + throw exception(AUTH_LOGIN_USER_DISABLED); + } + MemberUserUpdateReqVO update = new MemberUserUpdateReqVO(); update.setId(accessTokenDO.getUserId()); update.setAppRefreshDate(LocalDateTime.now()); @@ -371,9 +393,13 @@ public class MemberAuthServiceImpl implements MemberAuthService { @Override public AppAuthLoginRespVO.ChatToken reissueChatToken(Long userId) { - if (userService.getUser(userId) == null) { + MemberUserDO user = userService.getUser(userId); + if (user == null) { throw exception(USER_NOT_EXISTS); } + if (CommonStatusEnum.isDisable(user.getStatus())) { + throw exception(AUTH_LOGIN_USER_DISABLED); + } if (!userService.syncChatUser(userId)) { throw exception(CHAT_TOKEN_CREATE_FAILED); } @@ -388,6 +414,49 @@ public class MemberAuthServiceImpl implements MemberAuthService { return BeanUtils.toBean(loginRespVO, AppAuthLoginRespVO.ChatToken.class); } + @Override + @Transactional(rollbackFor = Exception.class) + public AppAuthLoginRespVO airlineLogin(AppAuthAirlineLoginReqVO reqVO) { + // 1. 校验航空账号:渠道、密码、有效期、设备绑定(手机号/设备号均按渠道隔离) + MemberAirlineAccountDO account = airlineAccountService.authenticateAndBindDevice( + reqVO.getMobile(), reqVO.getPassword(), reqVO.getDeviceId(), reqVO.getChannel()); + + // 2. 直接以航空账号身份签发 Token(不创建、不关联 member_user,避免污染会员表) + return createTokenAfterAirlineLoginSuccess(account, reqVO.getMobile()); + } + + /** + * 航空账号专用:签发 Token + 记录登录日志。 + * 注意:不能复用 {@link #createTokenAfterLoginSuccess} 和 {@link #createLoginLog}, + * 它们在成功时会更新 member_user(状态校验、最后登录时间),而航空账号没有对应的会员用户。 + * Token 的 userId 直接使用航空账号自身编号。 + */ + private AppAuthLoginRespVO createTokenAfterAirlineLoginSuccess(MemberAirlineAccountDO account, String mobile) { + // 插入登录日志(不更新 member_user 的最后登录信息) + LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO(); + reqDTO.setLogType(LoginLogTypeEnum.LOGIN_MOBILE.getType()); + reqDTO.setTraceId(TracerUtils.getTraceId()); + reqDTO.setUserId(account.getId()); + reqDTO.setUserType(getUserType().getValue()); + reqDTO.setUsername(mobile); + reqDTO.setUserAgent(ServletUtils.getUserAgent()); + reqDTO.setUserIp(getClientIP()); + reqDTO.setResult(LoginResultEnum.SUCCESS.getResult()); + loginLogApi.createLoginLog(reqDTO); + // 创建 Token 令牌(userId 使用航空账号自身编号) + // 使用独立的 airline 客户端:access/refresh token 有效期极长(见 system_oauth2_client 表), + // App 端登录一次即可,无需续期;每次打开 App 仅通过 airline-check 校验账号状态/有效期/设备绑定 + OAuth2AccessTokenRespDTO accessTokenRespDTO = oauth2TokenApi.createAccessToken(new OAuth2AccessTokenCreateReqDTO() + .setUserId(account.getId()).setUserType(getUserType().getValue()) + .setClientId(CLIENT_ID_AIRLINE)); + return AuthConvert.INSTANCE.convert(accessTokenRespDTO, null); + } + + @Override + public void airlineCheck(Long loginUserId, String mobile, String deviceId, String channel) { + airlineAccountService.validateAirlineSession(loginUserId, mobile, deviceId, channel); + } + private void createLogoutLog(Long userId) { LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO(); reqDTO.setLogType(LoginLogTypeEnum.LOGOUT_SELF.getType()); diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthService.java index 2b67f74..8cf46b2 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthService.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthService.java @@ -10,6 +10,7 @@ import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateManAuthReq import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWechatAuthReqVo; import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWechatErrorAuthReqVo; import cn.iocoder.yudao.module.member.controller.app.comauth.vo.CreateWomanAuthReqVo; +import cn.iocoder.yudao.module.member.client.vo.thirdfaceverify.ThirdFindFaceVerifyResVo; import cn.iocoder.yudao.module.member.dal.dataobject.comauth.ComAuthDO; /** @@ -32,11 +33,6 @@ public interface ComAuthService { */ ComAuthDO createWechatAuth(@Valid CreateWechatAuthReqVo dto); - /** - * 创建男性认证 - */ - ComAuthDO createManAuth(@Valid CreateManAuthReqVo dto); - /** * 创建女性认证 */ @@ -62,6 +58,11 @@ public interface ComAuthService { */ void cancelUserAuth(Long authId, Long loginUserId); + /** + * 查询阿里云人脸认证结果(DescribeFaceVerify),用于 SDK 2006 等失败排查 + */ + ThirdFindFaceVerifyResVo findFaceVerify(Long authId, Long loginUserId); + /** * 获得用户认证 */ diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthServiceImpl.java index 61ca10e..a56d8a7 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/comauth/ComAuthServiceImpl.java @@ -5,6 +5,7 @@ import cn.hutool.core.collection.IterUtil; import cn.hutool.core.date.DatePattern; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; import cn.hutool.json.JSONUtil; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; @@ -149,65 +150,36 @@ public class ComAuthServiceImpl implements ComAuthService { } /** - * 创建微信认证 + * 修改微信信息:校验微信号未被他人占用后直接更新会员资料,不创建认证记录。 */ @Override @Transactional(rollbackFor = Exception.class) public ComAuthDO createWechatAuth(CreateWechatAuthReqVo dto) { + String wechatNumber = StrUtil.trim(dto.getWechatNumber()); + if (StrUtil.isBlank(wechatNumber)) { + throw exception(MemberErrorCode.EMPTY, "微信号不能为空"); + } + dto.setWechatNumber(wechatNumber); - ComAuthDO.WechatAuthExtra extraObj = new ComAuthDO.WechatAuthExtra(); - extraObj.setWechatNumber(dto.getWechatNumber()); - extraObj.setWechatQrcode(dto.getWechatQrcode()); - - ComAuthDO saveObj = new ComAuthDO(); - saveObj.setAuthMemberId(dto.getMemberId()); - saveObj.setBizType(MemberComAuthEnum.WECHAT_NUMBER.getType()); - saveObj.setAuthStatus(MemberComAuthStatusEnum.WAIT_AUTH.getType()); - saveObj.setAuthPic(dto.getWechatQrcode()); - saveObj.setAuthExtraJson(JSONUtil.toJsonStr(extraObj)); - authMapper.insert(saveObj); - - ComAuthDO detail = authMapper.selectById(saveObj.getId()); - - // 查询配置 - MemberConfigDO config = memberConfigService.getConfig(); - if (ObjectUtil.isNotEmpty(config) && ObjectUtil.isNotEmpty(config.getAutoWechatNumAuth())) { - if (config.getAutoWechatNumAuth()) { - getSelf().wechatVerify(detail); - } + MemberUserDO occupied = memberUserMapper.selectByWechatNumExcludeId(wechatNumber, dto.getMemberId()); + if (ObjectUtil.isNotEmpty(occupied)) { + throw exception(MemberErrorCode.EMPTY, "请勿填写重复的微信号"); } - return detail; + MemberUserDO updateUser = new MemberUserDO() + .setId(dto.getMemberId()) + .setWechatNum(wechatNumber) + .setWechatQrCode(dto.getWechatQrcode()); + memberUserMapper.updateById(updateUser); + + ComAuthDO result = new ComAuthDO(); + result.setAuthMemberId(dto.getMemberId()); + result.setBizType(MemberComAuthEnum.WECHAT_NUMBER.getType()); + result.setAuthStatus(MemberComAuthStatusEnum.CHECK_PASS.getType()); + result.setAuthTime(LocalDateTime.now()); + return result; } - /** - * 创建男性认证 - */ - @Override - @Transactional(rollbackFor = Exception.class) - public ComAuthDO createManAuth(CreateManAuthReqVo dto) { - ComAuthDO startAuth = isStartAuth(dto.getMemberId()); - if (ObjectUtil.isNotEmpty(startAuth)) return startAuth; - // 新增 - ComAuthDO saveObj = new ComAuthDO(); - saveObj.setAuthMemberId(dto.getMemberId()); - saveObj.setBizType(MemberComAuthEnum.MAN_MEMBER_TRUE.getType()); - saveObj.setAuthStatus(MemberComAuthStatusEnum.WAIT_AUTH.getType()); - saveObj.setAuthPic(dto.getAuthPic()); - authMapper.insert(saveObj); - // 额外信息 - ComAuthDO.ManInfoAuthExtra memberInfoAuthExtra = new ComAuthDO.ManInfoAuthExtra(); - memberInfoAuthExtra.setMetaInfoJson(dto.getMetaInfoJson()); - // 更新auth - String authExtraJson = JSONUtil.toJsonStr(memberInfoAuthExtra); - ComAuthDO updateObj = new ComAuthDO(); - updateObj.setId(saveObj.getId()); - updateObj.setAuthExtraJson(authExtraJson); - authMapper.updateById(updateObj); - // 发起认证 - getSelf().startAuth(saveObj, authExtraJson); - return authMapper.selectById(saveObj.getId()); - } /** * 创建女性认证 @@ -216,7 +188,10 @@ public class ComAuthServiceImpl implements ComAuthService { @Transactional(rollbackFor = Exception.class) public ComAuthDO createWomanAuth(CreateWomanAuthReqVo dto) { ComAuthDO startAuth = isStartAuth(dto.getMemberId()); - if (ObjectUtil.isNotEmpty(startAuth)) return startAuth; + // 进行中的旧单直接作废:复用旧 certifyId 常导致 SDK 不弹刷脸页(客户端未提交) + if (ObjectUtil.isNotEmpty(startAuth)) { + authMapper.deleteById(startAuth.getId()); + } // 新增auth ComAuthDO saveObj = new ComAuthDO(); saveObj.setAuthMemberId(dto.getMemberId()); @@ -238,8 +213,8 @@ public class ComAuthServiceImpl implements ComAuthService { updateObj.setId(saveObj.getId()); updateObj.setAuthExtraJson(authExtraJson); authMapper.updateById(updateObj); - // 发起认证 - getSelf().startAuth(saveObj, authExtraJson); + // 发起认证:必须传客户端原始 MetaInfo,不能传整段 authExtraJson + getSelf().startAuth(saveObj, dto.getMetaInfoJson()); return authMapper.selectById(saveObj.getId()); } @@ -393,6 +368,37 @@ public class ComAuthServiceImpl implements ComAuthService { authMapper.deleteById(detail.getId()); } + /** + * 查询阿里云人脸认证结果(DescribeFaceVerify) + */ + @Override + public ThirdFindFaceVerifyResVo findFaceVerify(Long authId, Long loginUserId) { + ComAuthDO detail = authMapper.selectOne(new LambdaQueryWrapperX() + .eq(ComAuthDO::getId, authId) + .eq(ComAuthDO::getAuthMemberId, loginUserId) + ); + if (ObjectUtil.isEmpty(detail)) { + throw exception(MemberErrorCode.COM_AUTH_NOT_EXISTS); + } + if (ObjectUtil.isEmpty(detail.getBizId())) { + throw exception(MemberErrorCode.EMPTY, "认证单 certifyId 为空,无法查询阿里云结果"); + } + ThirdFindFaceVerifyReqVo dto = new ThirdFindFaceVerifyReqVo(); + dto.setCertifyId(detail.getBizId()); + ThirdFindFaceVerifyResVo res = thirdFaceVerifyService.findFaceVerify(dto); + if (ObjectUtil.isEmpty(res)) { + throw exception(MemberErrorCode.EMPTY, "查询阿里云认证结果失败,请稍后重试"); + } + // 落库失败原因,便于后台排查;不在此改 authStatus(仍由回调/轮询/客户端 cancel 处理) + if (ObjectUtil.isNotEmpty(res.getErrorMessage())) { + ComAuthDO updateObj = new ComAuthDO(); + updateObj.setId(detail.getId()); + updateObj.setFailReason(res.getErrorMessage()); + authMapper.updateById(updateObj); + } + return res; + } + /** * 获得用户认证 */ diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/interaction/MemberInteractionService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/interaction/MemberInteractionService.java new file mode 100644 index 0000000..9635145 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/interaction/MemberInteractionService.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.member.service.interaction; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.controller.app.interaction.vo.AppInteractionRespVO; +import cn.iocoder.yudao.module.member.controller.app.interaction.vo.AppInteractionSummaryRespVO; +import cn.iocoder.yudao.module.member.enums.interaction.MemberInteractionBizTypeEnum; + +public interface MemberInteractionService { + + /** + * 写入一条互动消息(接收人=被操作方) + */ + void create(Long receiverId, Long actorId, MemberInteractionBizTypeEnum bizType, Long refId); + + AppInteractionSummaryRespVO getSummary(Long receiverId); + + PageResult getPage(Long receiverId, PageParam pageParam); + + void readAll(Long receiverId); + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/interaction/MemberInteractionServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/interaction/MemberInteractionServiceImpl.java new file mode 100644 index 0000000..8cc0205 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/interaction/MemberInteractionServiceImpl.java @@ -0,0 +1,134 @@ +package cn.iocoder.yudao.module.member.service.interaction; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.controller.app.interaction.vo.AppInteractionRespVO; +import cn.iocoder.yudao.module.member.controller.app.interaction.vo.AppInteractionSummaryRespVO; +import cn.iocoder.yudao.module.member.dal.dataobject.interaction.MemberInteractionDO; +import cn.iocoder.yudao.module.member.dal.mysql.interaction.MemberInteractionMapper; +import cn.iocoder.yudao.module.member.enums.interaction.MemberInteractionBizTypeEnum; +import cn.iocoder.yudao.module.member.service.user.BO.MemberExtBO; +import cn.iocoder.yudao.module.member.service.user.MemberUserService; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@Validated +public class MemberInteractionServiceImpl implements MemberInteractionService { + + @Resource + private MemberInteractionMapper interactionMapper; + @Resource + private MemberUserService memberUserService; + + @Override + public void create(Long receiverId, Long actorId, MemberInteractionBizTypeEnum bizType, Long refId) { + if (receiverId == null || actorId == null || bizType == null) { + return; + } + if (ObjectUtil.equals(receiverId, actorId)) { + return; + } + MemberInteractionDO row = MemberInteractionDO.builder() + .receiverId(receiverId) + .actorId(actorId) + .bizType(bizType.getType()) + .refId(refId) + .readStatus(false) + .build(); + interactionMapper.insert(row); + } + + @Override + public AppInteractionSummaryRespVO getSummary(Long receiverId) { + AppInteractionSummaryRespVO vo = new AppInteractionSummaryRespVO(); + Long unread = interactionMapper.selectUnreadCount(receiverId); + vo.setUnreadCount(unread == null ? 0L : unread); + MemberInteractionDO latest = interactionMapper.selectLatest(receiverId); + if (latest == null) { + vo.setLastContent(""); + return vo; + } + List filled = fillActors(Collections.singletonList(latest)); + AppInteractionRespVO first = filled.isEmpty() ? null : filled.get(0); + vo.setLastContent(first != null ? first.getContent() : ""); + vo.setLastTime(latest.getCreateTime()); + return vo; + } + + @Override + public PageResult getPage(Long receiverId, PageParam pageParam) { + PageResult page = interactionMapper.selectPageByReceiver(receiverId, pageParam); + if (ObjectUtil.isEmpty(page.getList())) { + return PageResult.empty(page.getTotal()); + } + List list = fillActors(page.getList()); + return new PageResult<>(list, page.getTotal()); + } + + @Override + public void readAll(Long receiverId) { + interactionMapper.markAllRead(receiverId); + } + + private List fillActors(List rows) { + if (ObjectUtil.isEmpty(rows)) { + return Collections.emptyList(); + } + List actorIds = rows.stream().map(MemberInteractionDO::getActorId).distinct().collect(Collectors.toList()); + List members = memberUserService.getUserExtList(actorIds); + Map memberMap = new HashMap<>(); + if (CollUtil.isNotEmpty(members)) { + for (MemberExtBO member : members) { + if (member != null && member.getMemberId() != null) { + memberMap.put(member.getMemberId(), member); + } + } + } + + return rows.stream().map(row -> { + AppInteractionRespVO vo = new AppInteractionRespVO(); + vo.setId(row.getId()); + vo.setBizType(row.getBizType()); + vo.setActorId(row.getActorId()); + vo.setReadStatus(Boolean.TRUE.equals(row.getReadStatus())); + vo.setCreateTime(row.getCreateTime()); + MemberExtBO actor = memberMap.get(row.getActorId()); + String nickname = "用户"; + if (actor != null) { + vo.setActorAvatar(actor.getMemberAvatar()); + if (StrUtil.isNotBlank(actor.getMemberNickname())) { + nickname = actor.getMemberNickname(); + } + vo.setActorNickname(nickname); + } else { + vo.setActorNickname(nickname); + } + vo.setContent(buildContent(nickname, row.getBizType())); + return vo; + }).collect(Collectors.toList()); + } + + private String buildContent(String nickname, String bizType) { + MemberInteractionBizTypeEnum type = Arrays.stream(MemberInteractionBizTypeEnum.values()) + .filter(e -> ObjectUtil.equals(e.getType(), bizType)) + .findFirst() + .orElse(null); + if (type == null) { + return nickname + " 与你产生了互动"; + } + return nickname + " " + type.getContentSuffix(); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java index 462d25f..4a24adb 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/opentype/OpenTypeServiceImpl.java @@ -33,6 +33,12 @@ import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundCreateReqDTO; import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundRespDTO; import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum; +import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageRecordApi; +import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageAddDTO; +import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum; +import cn.iocoder.yudao.module.trade.api.brokerage.TradeBrokerageRecordApi; +import cn.iocoder.yudao.module.trade.api.brokerage.dto.BrokerageAddDTO; +import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -60,6 +66,7 @@ import static cn.hutool.core.util.ObjectUtil.notEqual; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.addTime; import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString; +import static cn.iocoder.yudao.framework.common.util.number.MoneyUtils.fenToYuanStr; import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; import static cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum.*; @@ -90,6 +97,8 @@ public class OpenTypeServiceImpl implements OpenTypeService { private PayOrderApi payOrderApi; @Resource private PayRefundApi payRefundApi; + @Resource + private TradeBrokerageRecordApi tradeBrokerageRecordApi; /** * 获取最后一条会员开通记录 @@ -423,10 +432,36 @@ public class OpenTypeServiceImpl implements OpenTypeService { // 添加会员tag memberRecentTagService.createOrUpdateVipTag(openTypeUserDO.getMemberId()); + // 添加会员开通分销佣金(每次开通都分,对应 OPEN_TYPE) + addOpenTypeBrokerage(openTypeUserDO, openType, payOrder); + String message = String.format("恭喜您购买成功,此次购买的产品:%s", openType.getTitle()); this.chatApi.messageUserSendText(openTypeUserDO.getMemberId(), message); } + /** + * 会员开通支付成功后,给上级增加分销佣金 + */ + private void addOpenTypeBrokerage(OpenTypeUserDO openTypeUserDO, OpenTypeDO openType, PayOrderRespDTO payOrder) { + Integer basePrice = payOrder.getPrice(); + if (basePrice == null || basePrice <= 0) { + log.info("[会员开通分佣][开通单({}) 支付金额为0,跳过分佣]", openTypeUserDO.getId()); + return; + } + MemberUserDO user = memberUserService.getUser(openTypeUserDO.getMemberId()); + String nickname = user != null && user.getNickname() != null ? user.getNickname() : String.valueOf(openTypeUserDO.getMemberId()); + String productTitle = openType != null && openType.getTitle() != null ? openType.getTitle() : "会员"; + List brokerageList = new ArrayList<>(); + brokerageList.add(new BrokerageAddDTO() + .setBizId(String.valueOf(openTypeUserDO.getId())) + .setBasePrice(basePrice) + .setFirstFixedPrice(0) + .setSecondFixedPrice(0) + .setSourceUserId(openTypeUserDO.getMemberId()) + .setTitle(String.format("用户[%s]开通会员[%s],支付[%s]元", nickname, productTitle, fenToYuanStr(basePrice)))); + tradeBrokerageRecordApi.addBrokerage(openTypeUserDO.getMemberId(), BrokerageRecordBizTypeEnum.OPEN_TYPE, brokerageList); + } + /** * 退款的回调 */ @@ -445,6 +480,8 @@ public class OpenTypeServiceImpl implements OpenTypeService { updateObj.setRefundStatus(SUCCESS.getStatus()) .setRefundTime(payRefund.getSuccessTime()) .setRefundPayPrice(openTypeUserDO.getPayPrice()); + // 退款成功后作废对应会员佣金 + tradeBrokerageRecordApi.cancelBrokerage(BrokerageRecordBizTypeEnum.OPEN_TYPE, String.valueOf(id)); } // 退款失败 if (PayRefundStatusEnum.isFailure(payRefund.getStatus())) { @@ -452,7 +489,7 @@ public class OpenTypeServiceImpl implements OpenTypeService { } // 更新会员开通的退款状态 openTypeUserMapper.update(updateObj, new LambdaQueryWrapperX() - .eq(OpenTypeUserDO::getId, openTypeUserDO.getOpenTypeId()) + .eq(OpenTypeUserDO::getId, id) .eq(OpenTypeUserDO::getRefundStatus, WAITING.getStatus()) ); } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceService.java new file mode 100644 index 0000000..986dff6 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceService.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.member.service.push; + +import cn.iocoder.yudao.module.member.controller.app.push.vo.AppPushBindReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushDeviceDO; + +import java.util.List; + +public interface MemberPushDeviceService { + + /** + * 绑定 / 更新当前登录用户的推送设备 + */ + void bind(Long memberId, AppPushBindReqVO reqVO); + + /** + * 解绑设备(仅允许解绑属于自己的 registrationId) + */ + void unbind(Long memberId, String registrationId); + + /** + * 查询用户全部绑定设备(后续服务端推送用) + */ + List getByMemberId(Long memberId); + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceServiceImpl.java new file mode 100644 index 0000000..7a82897 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushDeviceServiceImpl.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.module.member.service.push; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.member.controller.app.push.vo.AppPushBindReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushDeviceDO; +import cn.iocoder.yudao.module.member.dal.mysql.push.MemberPushDeviceMapper; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.List; + +@Service +@Validated +public class MemberPushDeviceServiceImpl implements MemberPushDeviceService { + + @Resource + private MemberPushDeviceMapper pushDeviceMapper; + + @Override + public void bind(Long memberId, AppPushBindReqVO reqVO) { + String registrationId = reqVO.getRegistrationId().trim(); + String provider = StrUtil.blankToDefault(reqVO.getProvider(), "jpush"); + String platform = reqVO.getPlatform().trim().toLowerCase(); + String alias = StrUtil.blankToDefault(reqVO.getAlias(), "u" + memberId); + + MemberPushDeviceDO existing = pushDeviceMapper.selectByRegistrationId(registrationId); + if (existing == null) { + MemberPushDeviceDO row = MemberPushDeviceDO.builder() + .memberId(memberId) + .registrationId(registrationId) + .alias(alias) + .platform(platform) + .provider(provider) + .build(); + pushDeviceMapper.insert(row); + return; + } + + MemberPushDeviceDO update = new MemberPushDeviceDO(); + update.setId(existing.getId()); + update.setMemberId(memberId); + update.setAlias(alias); + update.setPlatform(platform); + update.setProvider(provider); + pushDeviceMapper.updateById(update); + } + + @Override + public void unbind(Long memberId, String registrationId) { + if (StrUtil.isBlank(registrationId)) { + return; + } + MemberPushDeviceDO existing = pushDeviceMapper.selectByRegistrationId(registrationId.trim()); + if (existing == null) { + return; + } + // 仅允许解绑自己的设备,避免误删他人绑定 + if (!memberId.equals(existing.getMemberId())) { + return; + } + pushDeviceMapper.physicalDeleteById(existing.getId()); + } + + @Override + public List getByMemberId(Long memberId) { + return pushDeviceMapper.selectListByMemberId(memberId); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushService.java new file mode 100644 index 0000000..c28a2a5 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushService.java @@ -0,0 +1,12 @@ +package cn.iocoder.yudao.module.member.service.push; + +import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatPrivateMessageSendReqVO; + +public interface MemberPushService { + + /** + * 推送私聊消息通知 + */ + void pushPrivateMessage(ChatPrivateMessageSendReqVO message); + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushServiceImpl.java new file mode 100644 index 0000000..f5451e4 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/push/MemberPushServiceImpl.java @@ -0,0 +1,118 @@ +package cn.iocoder.yudao.module.member.service.push; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatPrivateMessageSendReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.push.MemberPushDeviceDO; +import cn.iocoder.yudao.module.member.framework.web.config.JPushProperties; +import cn.jpush.api.JPushClient; +import cn.jpush.api.push.PushResult; +import cn.jpush.api.push.model.Message; +import cn.jpush.api.push.model.Options; +import cn.jpush.api.push.model.Platform; +import cn.jpush.api.push.model.PushPayload; +import cn.jpush.api.push.model.audience.Audience; +import cn.jpush.api.push.model.notification.AndroidNotification; +import cn.jpush.api.push.model.notification.IosNotification; +import cn.jpush.api.push.model.notification.Notification; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class MemberPushServiceImpl implements MemberPushService { + + private static final int MAX_ALERT_LENGTH = 100; + /** Android 8+ 通知渠道,需与 App 端 JPush setChannelAndSound 的 channel_id 一致 */ + private static final String ANDROID_CHANNEL_ID = "htys_message"; + /** iOS 系统默认提示音 */ + private static final String IOS_SOUND = "default"; + /** 极光 Android alert_type:-1 表示默认(声音 + 震动等) */ + private static final int ANDROID_ALERT_TYPE_DEFAULT = -1; + + @Resource + private MemberPushDeviceService pushDeviceService; + @Resource + private MemberUserApi memberUserApi; + @Resource + private JPushProperties jPushProperties; + + @Override + public void pushPrivateMessage(ChatPrivateMessageSendReqVO message) { + if (!jPushProperties.isEnabled() || StrUtil.isBlank(jPushProperties.getAppKey()) + || StrUtil.isBlank(jPushProperties.getMasterSecret())) { + return; + } + List devices = pushDeviceService.getByMemberId(message.getRecvId()); + List registrationIds = devices.stream() + .filter(device -> StrUtil.equalsIgnoreCase(device.getProvider(), "jpush")) + .map(MemberPushDeviceDO::getRegistrationId) + .filter(StrUtil::isNotBlank) + .distinct() + .collect(Collectors.toList()); + if (CollUtil.isEmpty(registrationIds)) { + return; + } + + MemberUserRespDTO sender = memberUserApi.getUser(message.getSendId()); + String title = sender != null && StrUtil.isNotBlank(sender.getNickname()) ? sender.getNickname() : "新消息"; + String alert = buildAlert(message); + PushPayload payload = PushPayload.newBuilder() + .setPlatform(Platform.android_ios()) + .setAudience(Audience.registrationId(registrationIds)) + .setNotification(Notification.newBuilder() + .setAlert(alert) + .addPlatformNotification(AndroidNotification.newBuilder() + .setTitle(title) + .setChannelId(ANDROID_CHANNEL_ID) + .setAlertType(ANDROID_ALERT_TYPE_DEFAULT) + .build()) + .addPlatformNotification(IosNotification.newBuilder() + .incrBadge(1) + .setSound(IOS_SOUND) + .build()) + .build()) + .setMessage(Message.newBuilder().setTitle(title).setMsgContent(alert) + .addExtra("type", "private_message") + .addExtra("senderId", String.valueOf(message.getSendId())) + .addExtra("receiverId", String.valueOf(message.getRecvId())) + .addExtra("messageType", String.valueOf(message.getType())) + .build()) + .setOptions(Options.newBuilder().setApnsProduction(jPushProperties.isApnsProduction()).build()) + .build(); + try { + JPushClient client = new JPushClient(jPushProperties.getMasterSecret(), jPushProperties.getAppKey()); + PushResult result = client.sendPush(payload); + log.info("[pushPrivateMessage][sendId({}) recvId({}) registrationIds({}) msgId({}) 推送成功]", + message.getSendId(), message.getRecvId(), registrationIds.size(), result.msg_id); + } catch (Exception e) { + log.error("[pushPrivateMessage][sendId({}) recvId({}) 极光推送失败]", + message.getSendId(), message.getRecvId(), e); + } + } + + private String buildAlert(ChatPrivateMessageSendReqVO message) { + if (message.getType() == null || message.getType() == 0) { + return StrUtil.maxLength(message.getContent(), MAX_ALERT_LENGTH); + } + switch (message.getType()) { + case 1: + return "[图片]"; + case 2: + return "[文件]"; + case 3: + return "[语音]"; + case 4: + return "[视频]"; + default: + return "您收到一条新消息"; + } + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/RecentServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/RecentServiceImpl.java index 161eea6..86d96d1 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/RecentServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/RecentServiceImpl.java @@ -5,6 +5,7 @@ import cn.hutool.core.collection.IterUtil; import cn.hutool.core.date.DatePattern; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.util.number.NumberUtils; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.module.member.client.service.ChatApi; @@ -25,8 +26,10 @@ import cn.iocoder.yudao.module.member.enums.MemberRecentAuditSourceEnum; import cn.iocoder.yudao.module.member.enums.MemberRecentAuditStatusEnum; import cn.iocoder.yudao.module.member.enums.MemberRecentOperateEnum; import cn.iocoder.yudao.module.member.enums.MemberRecentSceneEnum; +import cn.iocoder.yudao.module.member.enums.interaction.MemberInteractionBizTypeEnum; import cn.iocoder.yudao.module.member.enums.tag.MemberRecentTagEnum; import cn.iocoder.yudao.module.member.service.config.MemberConfigService; +import cn.iocoder.yudao.module.member.service.interaction.MemberInteractionService; import cn.iocoder.yudao.module.member.service.location.LocationBO; import cn.iocoder.yudao.module.member.service.location.LocationService; import cn.iocoder.yudao.module.member.service.opentype.OpenTypeService; @@ -60,6 +63,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.member.dal.mysql.recent.RecentMapper; +import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper; import java.math.BigDecimal; import java.math.RoundingMode; @@ -80,6 +84,14 @@ public class RecentServiceImpl implements RecentService { @Resource private RecentMapper recentMapper; @Resource + private MemberUserMapper memberUserMapper; + + /** + * 首页(广场/附近)用户列表不可见账号:这些手机号的用户不会出现在首页用户列表中。 + * 如需增减隐藏账号,改这个列表即可。 + */ + private static final List SQUARE_HIDDEN_MOBILES = Arrays.asList("13311615193", "18800000001"); + @Resource private OperationMapper operationMapper; @Resource @@ -105,6 +117,8 @@ public class RecentServiceImpl implements RecentService { private TxMapApi txMapApi; @Resource private ChatApi chatApi; + @Resource + private MemberInteractionService memberInteractionService; /** * 创建朋友圈 @@ -200,12 +214,12 @@ public class RecentServiceImpl implements RecentService { convertRecentPageResVoSetupLocation(result, currentUserLastLocation); // 用户tag convertRecentPageResVoSetupTag(result); - if (ObjectUtil.isNotEmpty(result.getUserOperationStatus())) { - Boolean isLookWechat = result.getUserOperationStatus().getIsLookWechat(); - if (Boolean.TRUE.equals(isLookWechat)) { - result.getMember().setWechatNum(result.getMember().getWechatNum()); - } else { - result.getMember().setWechatNum(result.getMember().getwechatMaskString()); + if (ObjectUtil.isNotEmpty(result.getUserOperationStatus()) + && Boolean.TRUE.equals(result.getUserOperationStatus().getIsLookWechat()) + && ObjectUtil.isNotEmpty(result.getMember())) { + MemberWechatBO memberWechat = memberUserService.getMemberWechat(result.getMemberId()); + if (ObjectUtil.isNotEmpty(memberWechat)) { + result.getMember().setWechatNum(memberWechat.getWechatNum()); } } return result; @@ -402,6 +416,13 @@ public class RecentServiceImpl implements RecentService { operationObj.setBizType(MemberRecentOperateEnum.MEMBER_LOOK_WECHAT.getType()); operationObj.setTapTime(LocalDateTime.now()); operationMapper.insert(operationObj); + // 首次解锁:通知被解锁用户 + memberInteractionService.create( + memberId, + operateMemberId, + MemberInteractionBizTypeEnum.WECHAT_UNLOCKED, + operationObj.getId() + ); return memberWechat; } @@ -461,7 +482,7 @@ public class RecentServiceImpl implements RecentService { IPage recentPageList = recentMapper.pageJoin(new Page(bean.getPageNo(), bean.getPageSize()), resultQuery); List resultList = BeanUtils.toBean(recentPageList.getRecords(), RecentPageResVo.class); // 为RecentPageResVo设置Member信息 - convertRecentPageResVoSetupMember(resultList, isVip); + convertRecentPageResVoSetupMember(resultList, false); // 查询用户tag convertRecentPageResVoSetupTag(resultList); // 为RecentPageResVo设置OperationTotal信息 @@ -485,6 +506,18 @@ public class RecentServiceImpl implements RecentService { RecentPageReqVO resultQuery = BeanUtil.toBean(bean, RecentPageReqVO.class); resultQuery.setSceneBizType(bean.getScene().getType()); quickBeforeQuery(operationUser, operationCfg, resultQuery); + // 首页不可见账号:按手机号定位,合并进排除列表(广场/附近所有 tab 均不出现) + List excludeIds = resultQuery.getExcludeUserIdList() != null + ? new ArrayList<>(resultQuery.getExcludeUserIdList()) : new ArrayList<>(); + for (String hiddenMobile : SQUARE_HIDDEN_MOBILES) { + MemberUserDO hiddenUser = memberUserMapper.selectByMobile(hiddenMobile); + if (hiddenUser != null && !excludeIds.contains(hiddenUser.getId())) { + excludeIds.add(hiddenUser.getId()); + } + } + if (!excludeIds.isEmpty()) { + resultQuery.setExcludeUserIdList(excludeIds); + } // 广场-最近 if (ObjectUtil.equals(bean.getScene(), MemberRecentSceneEnum.SQUARE_NEW_TIME)) { resultQuery.setDistanceIsNotNull(true); @@ -501,8 +534,8 @@ public class RecentServiceImpl implements RecentService { } IPage recentPageList = recentMapper.pageUserJoin(new Page(bean.getPageNo(), bean.getPageSize()), resultQuery); List resultList = BeanUtils.toBean(recentPageList.getRecords(), RecentPageResVo.class); - // 为RecentPageResVo设置Member信息 - convertRecentPageResVoSetupMember(resultList, isVip); + // 为RecentPageResVo设置Member信息,微信号统一脱敏;明文只允许通过查看微信接口返回 + convertRecentPageResVoSetupMember(resultList, false); // 如果是`新人``女生`模糊`头像`和`用户名` List dimList = Arrays.asList(MemberRecentSceneEnum.SQUARE_NEW_PERSON, MemberRecentSceneEnum.SQUARE_FEMALE); // 查询用户tag @@ -766,13 +799,14 @@ public class RecentServiceImpl implements RecentService { */ @Override public RecentPageResVo getRecentUserByUserId(Long operateUserId, Long userId) { - // 判断当前操作用户是不是VIP - boolean isVip = openTypeService.isUserOpenType(operateUserId); - + MemberUserDO targetUser = memberUserService.getUser(userId); + if (targetUser == null || CommonStatusEnum.isDisable(targetUser.getStatus())) { + throw exception(MemberErrorCode.USER_NOT_EXISTS); + } RecentPageResVo result = new RecentPageResVo(); result.setMemberId(userId); - // 添加用户信息 - convertRecentPageResVoSetupMember(result, isVip); + // 添加用户信息,微信号统一脱敏 + convertRecentPageResVoSetupMember(result, false); // 查询用户最新的位置 LocationBO currentUserLastLocation = locationService.getCurrentUserLastLocation(); convertRecentPageResVoSetupLocation(result, currentUserLastLocation); @@ -791,12 +825,12 @@ public class RecentServiceImpl implements RecentService { if (ObjectUtil.isNotEmpty(evaluateDetailResVo)) { result.setEvaluateDetail(evaluateDetailResVo); } - if (ObjectUtil.isNotEmpty(result.getUserOperationStatus())) { - Boolean isLookWechat = result.getUserOperationStatus().getIsLookWechat(); - if (Boolean.TRUE.equals(isLookWechat)) { - result.getMember().setWechatNum(result.getMember().getWechatNum()); - } else { - result.getMember().setWechatNum(result.getMember().getwechatMaskString()); + if (ObjectUtil.isNotEmpty(result.getUserOperationStatus()) + && Boolean.TRUE.equals(result.getUserOperationStatus().getIsLookWechat()) + && ObjectUtil.isNotEmpty(result.getMember())) { + MemberWechatBO memberWechat = memberUserService.getMemberWechat(result.getMemberId()); + if (ObjectUtil.isNotEmpty(memberWechat)) { + result.getMember().setWechatNum(memberWechat.getWechatNum()); } } return result; diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/ViewRecordServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/ViewRecordServiceImpl.java index 0b1d71a..516d176 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/ViewRecordServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/recent/ViewRecordServiceImpl.java @@ -24,6 +24,8 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.member.dal.mysql.viewrecord.ViewRecordMapper; +import cn.iocoder.yudao.module.member.enums.interaction.MemberInteractionBizTypeEnum; +import cn.iocoder.yudao.module.member.service.interaction.MemberInteractionService; import java.util.List; import java.util.Map; @@ -42,6 +44,8 @@ public class ViewRecordServiceImpl implements ViewRecordService { private ViewRecordMapper viewRecordMapper; @Resource private MemberUserService memberUserService; + @Resource + private MemberInteractionService memberInteractionService; /** * 创建用户查看记录 @@ -56,6 +60,19 @@ public class ViewRecordServiceImpl implements ViewRecordService { viewRecord.setTitle("访问朋友圈"); } viewRecordMapper.insert(viewRecord); + + // 只要访问了他人主页(bizId=被访问用户),就写入「查看了你」; + // 与入口来源(USER/RECENT/CLASSIFY)无关;解锁微信是另一类 WECHAT_UNLOCKED。 + if (viewRecord.getBizId() != null + && viewRecord.getMemberId() != null + && ObjectUtil.notEqual(viewRecord.getBizId(), viewRecord.getMemberId())) { + memberInteractionService.create( + viewRecord.getBizId(), + viewRecord.getMemberId(), + MemberInteractionBizTypeEnum.VIEWED, + viewRecord.getId() + ); + } return viewRecord.getId(); } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRidePlanService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRidePlanService.java new file mode 100644 index 0000000..a3e30f1 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRidePlanService.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.member.service.rideplan; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanCreateReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanPageReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanRespVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideTagRespVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideUserTagUpdateReqVO; + +import java.util.List; + +/** + * 骑遇发车局 / 骑行标签 Service + * + * 鉴权约定:查询类方法 loginUserId 可为 null(游客,@PermitAll 接口透传); + * 发布/报名/举报/设置标签必须登录,userId 由 Controller 从登录态取。 + */ +public interface MemberRidePlanService { + + /** + * 发车局分页(免登录可查) + * + * @param loginUserId 当前登录用户,游客为 null + */ + PageResult getRidePlanPage(AppRidePlanPageReqVO reqVO, Long loginUserId); + + /** + * 发车局详情(免登录可查) + */ + AppRidePlanRespVO getRidePlan(Long id, Long loginUserId); + + /** + * 发布发车局(需登录) + */ + Long createRidePlan(Long userId, AppRidePlanCreateReqVO reqVO); + + /** + * 加入发车局(需登录) + */ + void joinRidePlan(Long userId, Long planId); + + /** + * 取消加入发车局(需登录) + */ + void cancelJoinRidePlan(Long userId, Long planId); + + /** + * 举报发车局(需登录);举报达到阈值自动下架 + */ + void reportRidePlan(Long userId, Long planId); + + /** + * 骑行标签字典(免登录可查,只返回启用的) + */ + List getRideTagList(); + + /** + * 我的骑行标签(需登录) + */ + List getMyRideTags(Long userId); + + /** + * 全量替换我的骑行标签(需登录) + */ + void updateMyRideTags(Long userId, AppRideUserTagUpdateReqVO reqVO); + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRidePlanServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRidePlanServiceImpl.java new file mode 100644 index 0000000..0dbc697 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRidePlanServiceImpl.java @@ -0,0 +1,266 @@ +package cn.iocoder.yudao.module.member.service.rideplan; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanCreateReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanPageReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRidePlanRespVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideTagRespVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideUserTagUpdateReqVO; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRidePlanDO; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRidePlanJoinDO; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideTagDO; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideUserTagDO; +import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; +import cn.iocoder.yudao.module.member.dal.mysql.rideplan.MemberRidePlanJoinMapper; +import cn.iocoder.yudao.module.member.dal.mysql.rideplan.MemberRidePlanMapper; +import cn.iocoder.yudao.module.member.dal.mysql.rideplan.MemberRideTagMapper; +import cn.iocoder.yudao.module.member.dal.mysql.rideplan.MemberRideUserTagMapper; +import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.RIDE_PLAN_ALREADY_JOINED; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.RIDE_PLAN_FULL; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.RIDE_PLAN_MEET_TIME_INVALID; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.RIDE_PLAN_NOT_EXISTS; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.RIDE_PLAN_NOT_JOINED; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.RIDE_TAG_NOT_EXISTS; + +@Service +@Validated +public class MemberRidePlanServiceImpl implements MemberRidePlanService { + + /** 举报自动下架阈值 */ + private static final int REPORT_LIMIT = 5; + /** 状态:正常 */ + private static final int STATUS_NORMAL = 0; + /** 状态:已取消/下架 */ + private static final int STATUS_OFFLINE = 1; + + @Resource + private MemberRidePlanMapper ridePlanMapper; + @Resource + private MemberRidePlanJoinMapper ridePlanJoinMapper; + @Resource + private MemberRideTagMapper rideTagMapper; + @Resource + private MemberRideUserTagMapper rideUserTagMapper; + @Resource + private MemberUserMapper userMapper; + + @Override + public PageResult getRidePlanPage(AppRidePlanPageReqVO reqVO, Long loginUserId) { + PageResult pageResult = ridePlanMapper.selectPage(reqVO); + return new PageResult<>(convertList(pageResult.getList(), loginUserId), pageResult.getTotal()); + } + + @Override + public AppRidePlanRespVO getRidePlan(Long id, Long loginUserId) { + MemberRidePlanDO plan = ridePlanMapper.selectByIdAndStatus(id, STATUS_NORMAL); + if (plan == null) { + throw exception(RIDE_PLAN_NOT_EXISTS); + } + return convertList(Collections.singletonList(plan), loginUserId).get(0); + } + + @Override + public Long createRidePlan(Long userId, AppRidePlanCreateReqVO reqVO) { + if (!reqVO.getMeetTime().isAfter(LocalDateTime.now())) { + throw exception(RIDE_PLAN_MEET_TIME_INVALID); + } + // 校验标签名都存在于字典(保持数据干净) + List tags = reqVO.getTags() == null ? Collections.emptyList() : reqVO.getTags(); + for (String tag : tags) { + if (rideTagMapper.selectByName(tag) == null) { + throw exception(RIDE_TAG_NOT_EXISTS, tag); + } + } + MemberRidePlanDO plan = MemberRidePlanDO.builder() + .userId(userId) + .routeName(reqVO.getRouteName()) + .meetPlace(reqVO.getMeetPlace()) + .meetTime(reqVO.getMeetTime()) + .distanceKm(reqVO.getDistanceKm()) + .city(StrUtil.nullToEmpty(reqVO.getCity())) + .tags(String.join(",", tags)) + .capacity(reqVO.getCapacity()) + .joinCount(0) + .reportCount(0) + .status(STATUS_NORMAL) + .build(); + ridePlanMapper.insert(plan); + return plan.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void joinRidePlan(Long userId, Long planId) { + MemberRidePlanDO plan = ridePlanMapper.selectByIdAndStatus(planId, STATUS_NORMAL); + if (plan == null) { + throw exception(RIDE_PLAN_NOT_EXISTS); + } + if (ridePlanJoinMapper.selectByPlanIdAndUserId(planId, userId) != null) { + throw exception(RIDE_PLAN_ALREADY_JOINED); + } + if (plan.getJoinCount() >= plan.getCapacity()) { + throw exception(RIDE_PLAN_FULL); + } + MemberRidePlanJoinDO join = MemberRidePlanJoinDO.builder() + .planId(planId).userId(userId).build(); + ridePlanJoinMapper.insert(join); + plan.setJoinCount(plan.getJoinCount() + 1); + ridePlanMapper.updateById(plan); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void cancelJoinRidePlan(Long userId, Long planId) { + MemberRidePlanDO plan = ridePlanMapper.selectByIdAndStatus(planId, STATUS_NORMAL); + if (plan == null) { + throw exception(RIDE_PLAN_NOT_EXISTS); + } + MemberRidePlanJoinDO join = ridePlanJoinMapper.selectByPlanIdAndUserId(planId, userId); + if (join == null) { + throw exception(RIDE_PLAN_NOT_JOINED); + } + ridePlanJoinMapper.deleteById(join.getId()); + plan.setJoinCount(Math.max(0, plan.getJoinCount() - 1)); + ridePlanMapper.updateById(plan); + } + + @Override + public void reportRidePlan(Long userId, Long planId) { + MemberRidePlanDO plan = ridePlanMapper.selectByIdAndStatus(planId, STATUS_NORMAL); + if (plan == null) { + throw exception(RIDE_PLAN_NOT_EXISTS); + } + plan.setReportCount(plan.getReportCount() + 1); + if (plan.getReportCount() >= REPORT_LIMIT) { + plan.setStatus(STATUS_OFFLINE); + } + ridePlanMapper.updateById(plan); + } + + @Override + public List getRideTagList() { + return rideTagMapper.selectListByStatus(STATUS_NORMAL).stream() + .map(tag -> BeanUtils.toBean(tag, AppRideTagRespVO.class)) + .collect(Collectors.toList()); + } + + @Override + public List getMyRideTags(Long userId) { + List userTags = rideUserTagMapper.selectListByUserId(userId); + if (userTags.isEmpty()) { + return Collections.emptyList(); + } + Set tagIds = userTags.stream() + .map(MemberRideUserTagDO::getTagId).collect(Collectors.toCollection(LinkedHashSet::new)); + Map tagMap = rideTagMapper.selectBatchIds(tagIds).stream() + .collect(Collectors.toMap(MemberRideTagDO::getId, Function.identity())); + return tagIds.stream() + .filter(tagMap::containsKey) + .map(id -> BeanUtils.toBean(tagMap.get(id), AppRideTagRespVO.class)) + .collect(Collectors.toList()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateMyRideTags(Long userId, AppRideUserTagUpdateReqVO reqVO) { + // 校验标签都存在且启用 + List tags = rideTagMapper.selectBatchIds(reqVO.getTagIds()); + if (tags.size() != new LinkedHashSet<>(reqVO.getTagIds()).size()) { + throw exception(RIDE_TAG_NOT_EXISTS); + } + // 全量替换 + rideUserTagMapper.deleteByUserId(userId); + for (Long tagId : new LinkedHashSet<>(reqVO.getTagIds())) { + rideUserTagMapper.insert(MemberRideUserTagDO.builder() + .userId(userId).tagId(tagId).build()); + } + } + + // ─── 私有:DO → RespVO 批量转换(补发起人、报名人、joinedByMe)────── + + private List convertList(List plans, Long loginUserId) { + if (plans == null || plans.isEmpty()) { + return Collections.emptyList(); + } + // 发起人 + Set hostIds = plans.stream() + .map(MemberRidePlanDO::getUserId).collect(Collectors.toCollection(LinkedHashSet::new)); + Map userMap = userMapper.selectBatchIds(hostIds).stream() + .collect(Collectors.toMap(MemberUserDO::getId, Function.identity())); + // 报名记录 + List planIds = plans.stream().map(MemberRidePlanDO::getId).collect(Collectors.toList()); + Map> joinMap = ridePlanJoinMapper.selectList( + MemberRidePlanJoinDO::getPlanId, planIds).stream() + .collect(Collectors.groupingBy(MemberRidePlanJoinDO::getPlanId)); + // 报名人资料 + Set joinUserIds = joinMap.values().stream() + .flatMap(List::stream).map(MemberRidePlanJoinDO::getUserId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Map joinUserMap = joinUserIds.isEmpty() ? Collections.emptyMap() + : userMapper.selectBatchIds(joinUserIds).stream() + .collect(Collectors.toMap(MemberUserDO::getId, Function.identity())); + + List result = new ArrayList<>(plans.size()); + for (MemberRidePlanDO plan : plans) { + AppRidePlanRespVO vo = BeanUtils.toBean(plan, AppRidePlanRespVO.class); + vo.setTags(splitTags(plan.getTags())); + // 发起人(只暴露昵称/头像) + MemberUserDO host = userMap.get(plan.getUserId()); + if (host != null) { + vo.setHostUserId(host.getId()); + vo.setHostNickname(host.getNickname()); + vo.setHostAvatar(host.getAvatar()); + } + // 报名列表(只暴露昵称/头像) + List joinedUsers = joinMap + .getOrDefault(plan.getId(), Collections.emptyList()).stream() + .map(join -> { + MemberUserDO u = joinUserMap.get(join.getUserId()); + AppRidePlanRespVO.JoinUser ju = new AppRidePlanRespVO.JoinUser(); + if (u != null) { + ju.setUserId(u.getId()); + ju.setNickname(u.getNickname()); + ju.setAvatar(u.getAvatar()); + } else { + ju.setUserId(join.getUserId()); + } + return ju; + }).collect(Collectors.toList()); + vo.setJoinedUsers(joinedUsers); + // 当前用户是否已加入 + vo.setJoinedByMe(loginUserId != null && joinedUsers.stream() + .anyMatch(j -> Objects.equals(j.getUserId(), loginUserId))); + result.add(vo); + } + return result; + } + + private List splitTags(String tags) { + if (StrUtil.isEmpty(tags)) { + return Collections.emptyList(); + } + return StrUtil.split(tags, ',').stream() + .filter(StrUtil::isNotEmpty).collect(Collectors.toList()); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordService.java new file mode 100644 index 0000000..16bd844 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordService.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.member.service.rideplan; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordCreateReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordPageReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordRespVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordStatsRespVO; + +import java.util.Collection; +import java.util.List; + +/** + * 骑遇骑行记录(战报)Service + */ +public interface MemberRideRecordService { + + /** + * 上报一次骑行战报(需登录) + */ + Long createRideRecord(Long userId, AppRideRecordCreateReqVO reqVO); + + /** + * 战报墙分页(免登录;登录后返回 likedByMe) + */ + PageResult getRideRecordPage(AppRideRecordPageReqVO reqVO, Long loginUserId); + + /** + * 我的骑行记录分页(需登录) + */ + PageResult getMyRideRecordPage(Long userId, AppRideRecordPageReqVO reqVO); + + /** + * 战报详情(免登录,含轨迹) + */ + AppRideRecordRespVO getRideRecord(Long id, Long loginUserId); + + /** + * 弯道天梯榜(免登录):按 road+corner 分组,组内按压弯角度降序 + */ + List getCornerRank(String roadName, String cornerName); + + /** + * 点赞/取消点赞(需登录,幂等切换),返回操作后是否处于点赞状态 + */ + boolean toggleLike(Long userId, Long recordId); + + /** + * 批量骑手战报统计(免登录):战报数 / 最佳压弯 / 总里程,供列表与主页骑行数据行 + */ + List getRideRecordStats(Collection userIds); + + /** + * 举报战报(需登录),达到阈值自动下架 + */ + void reportRideRecord(Long userId, Long recordId); + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordServiceImpl.java new file mode 100644 index 0000000..bfcf127 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/rideplan/MemberRideRecordServiceImpl.java @@ -0,0 +1,250 @@ +package cn.iocoder.yudao.module.member.service.rideplan; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordCreateReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordPageReqVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordRespVO; +import cn.iocoder.yudao.module.member.controller.app.rideplan.vo.AppRideRecordStatsRespVO; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideRecordDO; +import cn.iocoder.yudao.module.member.dal.dataobject.rideplan.MemberRideRecordLikeDO; +import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; +import cn.iocoder.yudao.module.member.dal.mysql.rideplan.MemberRideRecordLikeMapper; +import cn.iocoder.yudao.module.member.dal.mysql.rideplan.MemberRideRecordMapper; +import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.member.enums.MemberErrorCode.RIDE_RECORD_NOT_EXISTS; + +@Service +@Validated +public class MemberRideRecordServiceImpl implements MemberRideRecordService { + + /** 举报自动下架阈值 */ + private static final int REPORT_LIMIT = 5; + /** 状态:正常 */ + private static final int STATUS_NORMAL = 0; + /** 状态:已下架 */ + private static final int STATUS_OFFLINE = 1; + /** 天梯榜单弯道最多展示条数 */ + private static final int RANK_LIMIT = 100; + /** 每条弯道榜单最多人数 */ + private static final int CORNER_BOARD_SIZE = 5; + + @Resource + private MemberRideRecordMapper rideRecordMapper; + @Resource + private MemberRideRecordLikeMapper rideRecordLikeMapper; + @Resource + private MemberUserMapper userMapper; + + @Override + public Long createRideRecord(Long userId, AppRideRecordCreateReqVO reqVO) { + MemberRideRecordDO record = MemberRideRecordDO.builder() + .userId(userId) + .title(reqVO.getTitle()) + .roadName(StrUtil.nullToEmpty(reqVO.getRoadName())) + .cornerName(StrUtil.nullToEmpty(reqVO.getCornerName())) + .distanceKm(reqVO.getDistanceKm()) + .durationSec(reqVO.getDurationSec()) + .topSpeedKmh(reqVO.getTopSpeedKmh()) + .curveCount(reqVO.getCurveCount()) + .maxLean(reqVO.getMaxLean()) + .trackJson(reqVO.getTrackJson()) + .likeCount(0) + .reportCount(0) + .status(STATUS_NORMAL) + .build(); + rideRecordMapper.insert(record); + return record.getId(); + } + + @Override + public PageResult getRideRecordPage(AppRideRecordPageReqVO reqVO, Long loginUserId) { + PageResult pageResult = rideRecordMapper.selectPage(reqVO); + return new PageResult<>(convertList(pageResult.getList(), loginUserId, false), pageResult.getTotal()); + } + + @Override + public PageResult getMyRideRecordPage(Long userId, AppRideRecordPageReqVO reqVO) { + reqVO.setUserId(null); // my 接口以登录身份为准,忽略过滤参数 + PageResult pageResult = rideRecordMapper.selectMyPage(userId, reqVO); + return new PageResult<>(convertList(pageResult.getList(), userId, false), pageResult.getTotal()); + } + + @Override + public AppRideRecordRespVO getRideRecord(Long id, Long loginUserId) { + MemberRideRecordDO record = rideRecordMapper.selectByIdAndStatus(id, STATUS_NORMAL); + if (record == null) { + throw exception(RIDE_RECORD_NOT_EXISTS); + } + return convertList(Collections.singletonList(record), loginUserId, true).get(0); + } + + @Override + public List getCornerRank(String roadName, String cornerName) { + List records = rideRecordMapper.selectRank(roadName, cornerName, RANK_LIMIT); + if (records.isEmpty()) { + return Collections.emptyList(); + } + // 骑手资料 + Set userIds = records.stream() + .map(MemberRideRecordDO::getUserId).collect(Collectors.toCollection(LinkedHashSet::new)); + Map userMap = userMapper.selectBatchIds(userIds).stream() + .collect(Collectors.toMap(MemberUserDO::getId, u -> u)); + // 按 road+corner 分组(保持 maxLean 降序,selectRank 已排序) + Map> grouped = new LinkedHashMap<>(); + for (MemberRideRecordDO record : records) { + grouped.computeIfAbsent(record.getRoadName() + "§" + record.getCornerName(), + k -> new ArrayList<>()).add(record); + } + List boards = new ArrayList<>(grouped.size()); + for (List group : grouped.values()) { + MemberRideRecordDO first = group.get(0); + AppRideRecordRespVO.CornerBoard board = new AppRideRecordRespVO.CornerBoard(); + board.setRoadName(first.getRoadName()); + board.setCornerName(first.getCornerName()); + board.setEntries(group.stream().limit(CORNER_BOARD_SIZE) + .map(r -> { + AppRideRecordRespVO.RankItem item = new AppRideRecordRespVO.RankItem(); + item.setRecordId(r.getId()); + item.setRoadName(r.getRoadName()); + item.setCornerName(r.getCornerName()); + item.setMaxLean(r.getMaxLean()); + item.setTopSpeedKmh(r.getTopSpeedKmh()); + item.setUserId(r.getUserId()); + item.setCreateTime(r.getCreateTime()); + MemberUserDO u = userMap.get(r.getUserId()); + if (u != null) { + item.setNickname(u.getNickname()); + item.setAvatar(u.getAvatar()); + } + return item; + }).collect(Collectors.toList())); + for (int i = 0; i < board.getEntries().size(); i++) { + board.getEntries().get(i).setRank(i + 1); + } + boards.add(board); + } + return boards; + } + + @Override + public List getRideRecordStats(Collection userIds) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyList(); + } + List records = rideRecordMapper.selectNormalByUserIds(userIds); + if (records.isEmpty()) { + return Collections.emptyList(); + } + Map> grouped = records.stream() + .collect(Collectors.groupingBy(MemberRideRecordDO::getUserId)); + List statsList = new ArrayList<>(grouped.size()); + for (Map.Entry> entry : grouped.entrySet()) { + java.math.BigDecimal best = java.math.BigDecimal.ZERO; + java.math.BigDecimal totalKm = java.math.BigDecimal.ZERO; + for (MemberRideRecordDO r : entry.getValue()) { + if (r.getMaxLean() != null && r.getMaxLean().compareTo(best) > 0) { + best = r.getMaxLean(); + } + if (r.getDistanceKm() != null) { + totalKm = totalKm.add(r.getDistanceKm()); + } + } + AppRideRecordStatsRespVO vo = new AppRideRecordStatsRespVO(); + vo.setUserId(entry.getKey()); + vo.setReportCount((long) entry.getValue().size()); + vo.setBestLean(best.doubleValue()); + vo.setTotalKm(totalKm.doubleValue()); + statsList.add(vo); + } + return statsList; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean toggleLike(Long userId, Long recordId) { + MemberRideRecordDO record = rideRecordMapper.selectByIdAndStatus(recordId, STATUS_NORMAL); + if (record == null) { + throw exception(RIDE_RECORD_NOT_EXISTS); + } + MemberRideRecordLikeDO like = rideRecordLikeMapper.selectByRecordIdAndUserId(recordId, userId); + if (like != null) { + rideRecordLikeMapper.deleteById(like.getId()); + record.setLikeCount(Math.max(0, record.getLikeCount() - 1)); + rideRecordMapper.updateById(record); + return false; + } + rideRecordLikeMapper.insert(MemberRideRecordLikeDO.builder() + .recordId(recordId).userId(userId).build()); + record.setLikeCount(record.getLikeCount() + 1); + rideRecordMapper.updateById(record); + return true; + } + + @Override + public void reportRideRecord(Long userId, Long recordId) { + MemberRideRecordDO record = rideRecordMapper.selectByIdAndStatus(recordId, STATUS_NORMAL); + if (record == null) { + throw exception(RIDE_RECORD_NOT_EXISTS); + } + record.setReportCount(record.getReportCount() + 1); + if (record.getReportCount() >= REPORT_LIMIT) { + record.setStatus(STATUS_OFFLINE); + } + rideRecordMapper.updateById(record); + } + + // ─── 私有:DO → RespVO 批量转换(补骑手资料、likedByMe)────── + + private List convertList(List records, Long loginUserId, + boolean withTrack) { + if (records == null || records.isEmpty()) { + return Collections.emptyList(); + } + // 骑手资料 + Set userIds = records.stream() + .map(MemberRideRecordDO::getUserId).collect(Collectors.toCollection(LinkedHashSet::new)); + Map userMap = userMapper.selectBatchIds(userIds).stream() + .collect(Collectors.toMap(MemberUserDO::getId, u -> u)); + // 当前用户对这批战报的点赞 + Set likedRecordIds = Collections.emptySet(); + if (loginUserId != null) { + List recordIds = records.stream().map(MemberRideRecordDO::getId).collect(Collectors.toList()); + likedRecordIds = rideRecordLikeMapper.selectListByUserIdAndRecordIds(loginUserId, recordIds).stream() + .map(MemberRideRecordLikeDO::getRecordId).collect(Collectors.toSet()); + } + Set finalLiked = likedRecordIds; + return records.stream().map(record -> { + AppRideRecordRespVO vo = BeanUtils.toBean(record, AppRideRecordRespVO.class); + if (!withTrack) { + vo.setTrackJson(null); // 列表不带轨迹,减小响应体 + } + MemberUserDO user = userMap.get(record.getUserId()); + if (user != null) { + vo.setNickname(user.getNickname()); + vo.setAvatar(user.getAvatar()); + } + vo.setLikedByMe(loginUserId != null && finalLiked.contains(record.getId())); + return vo; + }).collect(Collectors.toList()); + } + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserService.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserService.java index cc64a79..da86c9c 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserService.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserService.java @@ -140,6 +140,14 @@ public interface MemberUserService { */ boolean isPasswordMatch(String rawPassword, String encodedPassword); + /** + * 直接覆盖用户密码(航空账号同步等内部场景) + * + * @param id 用户编号 + * @param password 明文密码 + */ + void updateUserPassword(Long id, String password); + /** * 【管理员】更新会员用户 * @@ -147,6 +155,14 @@ public interface MemberUserService { */ void updateUser(@Valid MemberUserUpdateReqVO updateReqVO); + /** + * 【管理员】更新会员用户状态(禁用时踢出全部登录态) + * + * @param id 用户编号 + * @param status 状态,见 {@link cn.iocoder.yudao.framework.common.enums.CommonStatusEnum} + */ + void updateUserStatus(Long id, Integer status); + /** * 【管理员】获得会员用户分页 * diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java index e19b154..d30f830 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/service/user/MemberUserServiceImpl.java @@ -12,6 +12,7 @@ import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore; import cn.iocoder.yudao.module.member.client.service.ChatApi; import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatCreateOrUpdateUserReqVO; +import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatPrivateMessageSendResVO; import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatUserOnLineStatusReqVo; import cn.iocoder.yudao.module.member.client.vo.chat.user.ChatUserOnLineStatusRespVo; import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserPageReqVO; @@ -30,6 +31,7 @@ import cn.iocoder.yudao.module.member.enums.tag.MemberRecentTagEnum; import cn.iocoder.yudao.module.member.mq.producer.user.MemberUserProducer; import cn.iocoder.yudao.module.member.service.user.BO.MemberExtBO; import cn.iocoder.yudao.module.member.service.user.BO.MemberWechatBO; +import cn.iocoder.yudao.module.system.api.oauth2.OAuth2TokenApi; import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi; import cn.iocoder.yudao.module.system.api.sms.dto.code.SmsCodeUseReqDTO; import cn.iocoder.yudao.module.system.api.social.SocialClientApi; @@ -100,6 +102,8 @@ public class MemberUserServiceImpl implements MemberUserService { private ChatApi chatApi; @Resource private TradeBrokerageUserApi tradeBrokerageUserApi; + @Resource + private OAuth2TokenApi oauth2TokenApi; @Override public MemberUserDO getUserByMobile(String mobile) { @@ -402,6 +406,13 @@ public class MemberUserServiceImpl implements MemberUserService { return passwordEncoder.matches(rawPassword, encodedPassword); } + @Override + public void updateUserPassword(Long id, String password) { + validateUserExists(id); + memberUserMapper.updateById(MemberUserDO.builder().id(id) + .password(encodePassword(password)).build()); + } + /** * 对密码进行加密 * @@ -416,7 +427,7 @@ public class MemberUserServiceImpl implements MemberUserService { @Transactional(rollbackFor = Exception.class) public void updateUser(MemberUserUpdateReqVO updateReqVO) { // 校验存在 - validateUserExists(updateReqVO.getId()); + MemberUserDO dbUser = validateUserExists(updateReqVO.getId()); // 校验手机唯一 validateMobileUnique(updateReqVO.getId(), updateReqVO.getMobile()); @@ -446,6 +457,59 @@ public class MemberUserServiceImpl implements MemberUserService { // 更新用户新人tag memberRecentTagService.createOrUpdateNewPersonTag(updateReqVO.getId()); memberUserMapper.updateById(updateObj); + + // 变为禁用:踢出全部 App 登录态 + Integer newStatus = updateObj.getStatus(); + if (newStatus == null && updateReqVO.getStatus() != null) { + newStatus = updateReqVO.getStatus().intValue(); + } + if (CommonStatusEnum.isDisable(newStatus) && !CommonStatusEnum.isDisable(dbUser.getStatus())) { + kickMemberLogin(updateReqVO.getId()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateUserStatus(Long id, Integer status) { + MemberUserDO dbUser = validateUserExists(id); + if (ObjectUtil.equal(dbUser.getStatus(), status)) { + // 已禁用时再点一次,仍强制踢一次,避免漏踢 + if (CommonStatusEnum.isDisable(status)) { + kickMemberLogin(id); + } + return; + } + memberUserMapper.updateById(new MemberUserDO().setId(id).setStatus(status)); + if (CommonStatusEnum.isDisable(status)) { + kickMemberLogin(id); + } + } + + /** + * 清理会员全部 OAuth2 access + refresh,并通过 IM 私聊通知 App 踢登录(不封禁 IM 账号)。 + */ + private void kickMemberLogin(Long userId) { + // 先发 IM 消息(WS 仍在线时更容易送达),再清业务 token + try { + syncChatUser(userId); + // 约定前缀:Flutter 收到后先跳登录页,再清理本地态(不走 /third/user/ban) + ChatPrivateMessageSendResVO sent = chatApi.messageUserSendText( + userId, "__APP_KICK_SESSION__:账号已被禁用"); + if (sent == null) { + log.warn("禁用踢登录:IM 私聊通知未发出, userId={}", userId); + } else { + log.info("禁用踢登录:已发 IM 私聊通知, userId={}", userId); + } + } catch (Exception e) { + log.warn("禁用踢登录:IM 私聊通知失败, userId={}", userId, e); + } + try { + oauth2TokenApi.removeAccessTokenByUserId(userId, UserTypeEnum.MEMBER.getValue()); + log.info("已清理会员登录 token, userId={}", userId); + } catch (Exception e) { + log.warn("踢出会员登录态失败, userId={}", userId, e); + throw e; + } } @VisibleForTesting @@ -536,12 +600,15 @@ public class MemberUserServiceImpl implements MemberUserService { /** * 获取用户的微信信息 + * 有微信号即可解锁;二维码为可选展示字段。 */ @Override public MemberWechatBO getMemberWechat(Long memberId) { MemberUserDO detail = memberUserMapper.selectById(memberId); - if (ObjectUtil.isEmpty(detail)) return null; - if (ObjectUtil.isEmpty(detail.getWechatNum()) || ObjectUtil.isEmpty(detail.getWechatQrCode())) { + if (ObjectUtil.isEmpty(detail)) { + return null; + } + if (ObjectUtil.isEmpty(detail.getWechatNum())) { return null; } MemberWechatBO result = new MemberWechatBO(); diff --git a/yudao-module-member/yudao-module-member-biz/src/main/resources/mapper/RecentMapper.xml b/yudao-module-member/yudao-module-member-biz/src/main/resources/mapper/RecentMapper.xml index 7c862ee..8961aa2 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/resources/mapper/RecentMapper.xml +++ b/yudao-module-member/yudao-module-member-biz/src/main/resources/mapper/RecentMapper.xml @@ -80,6 +80,8 @@ left join member_user u on a.member_id = u.id + + and u.status = 0 and u.sex = #{dto.memberSex} @@ -131,6 +133,8 @@ left join member_open_type_user otu on u.id = otu.member_id u.deleted = 0 + + and u.status = 0 and u.sex = #{dto.memberSex} diff --git a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessorTest.java b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessorTest.java index 3f94584..9ff587f 100644 --- a/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessorTest.java +++ b/yudao-module-member/yudao-module-member-biz/src/test/java/cn/iocoder/yudao/module/member/client/service/FaceVerifyImageProcessorTest.java @@ -42,13 +42,16 @@ class FaceVerifyImageProcessorTest { } @Test - void normalizeImageRejectsLandscapeImage() throws Exception { + void normalizeImageConvertsLandscapeToPortraitByCenterCrop() throws Exception { byte[] source = createPng(800, 600); - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> FaceVerifyImageProcessor.normalizeImage(source)); + byte[] result = FaceVerifyImageProcessor.normalizeImage(source); - assertTrue(exception.getMessage().contains("竖图")); + BufferedImage resultImage = ImageIO.read(new ByteArrayInputStream(result)); + assertNotNull(resultImage); + assertTrue(resultImage.getHeight() > resultImage.getWidth()); + assertTrue(resultImage.getWidth() >= 480); + assertTrue(resultImage.getHeight() >= 640); } @Test diff --git a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApi.java b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApi.java index 79393cf..2fec551 100644 --- a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApi.java +++ b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApi.java @@ -37,6 +37,15 @@ public interface OAuth2TokenApi { */ OAuth2AccessTokenRespDTO removeAccessToken(String accessToken); + /** + * 移除指定用户的全部访问令牌与刷新令牌(踢下线) + * + * @param userId 用户编号 + * @param userType 用户类型 + * @return 删除的访问令牌数量 + */ + int removeAccessTokenByUserId(Long userId, Integer userType); + /** * 刷新访问令牌 * diff --git a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java index bc972b3..c6594c9 100644 --- a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java +++ b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java @@ -167,4 +167,10 @@ public interface ErrorCodeConstants { // ========== 站内信发送 1-002-028-000 ========== ErrorCode NOTIFY_SEND_TEMPLATE_PARAM_MISS = new ErrorCode(1_002_028_000, "模板参数({})缺失"); + // ========== 子账号管理 1-002-030-000 ========= + ErrorCode SUB_ACCOUNT_NOT_EXISTS = new ErrorCode(1_002_030_000, "子账号不存在"); + ErrorCode SUB_ACCOUNT_NOT_OWNER = new ErrorCode(1_002_030_001, "无权操作该子账号"); + ErrorCode SUB_ACCOUNT_MENU_EMPTY = new ErrorCode(1_002_030_002, "请至少勾选一个当前账号拥有的菜单"); + ErrorCode SUB_ACCOUNT_HOLDER_MENU_EMPTY = new ErrorCode(1_002_030_003, "当前账号未拥有任何菜单,无法创建子账号"); + } diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApiImpl.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApiImpl.java index be7eba3..dfcaf5c 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApiImpl.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/oauth2/OAuth2TokenApiImpl.java @@ -40,6 +40,11 @@ public class OAuth2TokenApiImpl implements OAuth2TokenApi { return BeanUtils.toBean(accessTokenDO, OAuth2AccessTokenRespDTO.class); } + @Override + public int removeAccessTokenByUserId(Long userId, Integer userType) { + return oauth2TokenService.removeAccessTokenByUserId(userId, userType); + } + @Override public OAuth2AccessTokenRespDTO refreshAccessToken(String refreshToken, String clientId) { OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.refreshAccessToken(refreshToken, clientId); diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/SubAccountController.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/SubAccountController.java new file mode 100644 index 0000000..76f39b5 --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/SubAccountController.java @@ -0,0 +1,88 @@ +package cn.iocoder.yudao.module.system.controller.admin.subaccount; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.*; +import cn.iocoder.yudao.module.system.service.subaccount.SubAccountService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.annotation.Resource; +import javax.validation.Valid; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 后台子账号管理 Controller + * + * 供 AppHolder 管理自己的子账号:创建、分配菜单、禁用、重置密码、删除。 + */ +@Tag(name = "管理后台 - 子账号管理") +@RestController +@RequestMapping("/system/sub-account") +@Validated +public class SubAccountController { + + @Resource + private SubAccountService subAccountService; + + @GetMapping("/list") + @Operation(summary = "获得子账号列表") + @PreAuthorize("@ss.hasPermission('system:sub-account:manage')") + public CommonResult> getSubAccountList() { + return success(subAccountService.getSubAccountList()); + } + + @GetMapping("/menu-list") + @Operation(summary = "获得当前账号拥有的菜单(用于给子账号勾选)") + @PreAuthorize("@ss.hasPermission('system:sub-account:manage')") + public CommonResult> getHolderMenuList() { + return success(subAccountService.getHolderMenuList()); + } + + @PostMapping("/create") + @Operation(summary = "创建子账号") + @PreAuthorize("@ss.hasPermission('system:sub-account:manage')") + public CommonResult createSubAccount(@Valid @RequestBody SubAccountCreateReqVO reqVO) { + return success(subAccountService.createSubAccount(reqVO, reqVO.getPassword())); + } + + @PutMapping("/update") + @Operation(summary = "修改子账号(昵称 + 可见菜单)") + @PreAuthorize("@ss.hasPermission('system:sub-account:manage')") + public CommonResult updateSubAccount(@RequestParam("userId") Long userId, + @Valid @RequestBody SubAccountSaveReqVO reqVO) { + subAccountService.updateSubAccount(userId, reqVO); + return success(true); + } + + @PutMapping("/update-status") + @Operation(summary = "修改子账号状态") + @PreAuthorize("@ss.hasPermission('system:sub-account:manage')") + public CommonResult updateSubAccountStatus(@Valid @RequestBody SubAccountUpdateStatusReqVO reqVO) { + subAccountService.updateSubAccountStatus(reqVO.getUserId(), reqVO.getStatus()); + return success(true); + } + + @PutMapping("/reset-password") + @Operation(summary = "重置子账号密码") + @PreAuthorize("@ss.hasPermission('system:sub-account:manage')") + public CommonResult resetSubAccountPassword(@Valid @RequestBody SubAccountResetPasswordReqVO reqVO) { + subAccountService.resetSubAccountPassword(reqVO.getUserId(), reqVO.getPassword()); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除子账号") + @Parameter(name = "userId", description = "子账号用户编号", required = true) + @PreAuthorize("@ss.hasPermission('system:sub-account:manage')") + public CommonResult deleteSubAccount(@RequestParam("userId") Long userId) { + subAccountService.deleteSubAccount(userId); + return success(true); + } + +} diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountCreateReqVO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountCreateReqVO.java new file mode 100644 index 0000000..d3fac7d --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountCreateReqVO.java @@ -0,0 +1,17 @@ +package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.hibernate.validator.constraints.Length; + +@Schema(description = "管理后台 - 子账号创建 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class SubAccountCreateReqVO extends SubAccountSaveReqVO { + + @Schema(description = "登录密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") + @Length(min = 4, max = 16, message = "密码长度为 4-16 位") + private String password; + +} diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountMenuRespVO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountMenuRespVO.java new file mode 100644 index 0000000..823173d --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountMenuRespVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - 子账号可用菜单 Response VO") +@Data +public class SubAccountMenuRespVO { + + @Schema(description = "菜单编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2262") + private Long id; + + @Schema(description = "父菜单编号", example = "0") + private Long parentId; + + @Schema(description = "菜单名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "会员中心") + private String name; + + @Schema(description = "菜单类型:1 目录 2 菜单 3 按钮", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer type; + + @Schema(description = "显示顺序", example = "1") + private Integer sort; + +} diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountResetPasswordReqVO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountResetPasswordReqVO.java new file mode 100644 index 0000000..8386bba --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountResetPasswordReqVO.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; + +@Schema(description = "管理后台 - 子账号重置密码 Request VO") +@Data +public class SubAccountResetPasswordReqVO { + + @Schema(description = "子账号用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + @NotNull(message = "子账号编号不能为空") + private Long userId; + + @Schema(description = "新密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") + @NotBlank(message = "新密码不能为空") + @Size(min = 4, max = 16, message = "密码长度为 4-16 位") + private String password; + +} diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountRespVO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountRespVO.java new file mode 100644 index 0000000..4bb9589 --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountRespVO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.Set; + +@Schema(description = "管理后台 - 子账号 Response VO") +@Data +public class SubAccountRespVO { + + @Schema(description = "记录编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long id; + + @Schema(description = "子账号用户编号(system_users.id)", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + private Long userId; + + @Schema(description = "登录账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "suboperator") + private String username; + + @Schema(description = "昵称", example = "运营小号") + private String nickname; + + @Schema(description = "状态:0 开启 1 禁用", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "最后登录时间") + private LocalDateTime loginDate; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "可见菜单编号列表") + private Set menuIds; + +} diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountSaveReqVO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountSaveReqVO.java new file mode 100644 index 0000000..4d2fd9e --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountSaveReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import java.util.Set; + +@Schema(description = "管理后台 - 子账号创建/修改 Request VO") +@Data +public class SubAccountSaveReqVO { + + @Schema(description = "子账号用户名(创建必填,字母+数字 4-30 位)", example = "suboperator") + @Size(min = 4, max = 30, message = "用户账号长度为 4-30 个字符") + private String username; + + @Schema(description = "子账号昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "运营小号") + @NotBlank(message = "子账号昵称不能为空") + @Size(max = 30, message = "子账号昵称长度不能超过30个字符") + private String nickname; + + @Schema(description = "菜单编号列表(必须在当前登录账号拥有的菜单范围内)", example = "[2262, 2317]") + private Set menuIds; + +} diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountUpdateStatusReqVO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountUpdateStatusReqVO.java new file mode 100644 index 0000000..0b288a6 --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/subaccount/vo/SubAccountUpdateStatusReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.system.controller.admin.subaccount.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.NotNull; + +@Schema(description = "管理后台 - 子账号状态修改 Request VO") +@Data +public class SubAccountUpdateStatusReqVO { + + @Schema(description = "子账号用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + @NotNull(message = "子账号编号不能为空") + private Long userId; + + @Schema(description = "状态:0 开启 1 禁用", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + private Integer status; + +} diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/subaccount/SubAccountDO.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/subaccount/SubAccountDO.java new file mode 100644 index 0000000..8e878cf --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/subaccount/SubAccountDO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.system.dal.dataobject.subaccount; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 后台子账号 DO + * + * AppHolder 创建的子账号与专属角色的绑定关系。 + * 子账号登录后台后可见的菜单 = 其专属角色被分配的菜单(由 holder 分配)。 + */ +@TableName("system_sub_account") +@KeySequence("system_sub_account_seq") +@Data +@EqualsAndHashCode(callSuper = true) +public class SubAccountDO extends TenantBaseDO { + + /** + * 主键 + */ + @TableId + private Long id; + /** + * 管理者(holder)用户编号,system_users.id + */ + private Long holderUserId; + /** + * 子账号用户编号,system_users.id + */ + private Long subUserId; + /** + * 子账号专属角色编号,system_role.id + */ + private Long roleId; + +} diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2AccessTokenMapper.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2AccessTokenMapper.java index 81ca13f..1fa2e0e 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2AccessTokenMapper.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2AccessTokenMapper.java @@ -23,6 +23,12 @@ public interface OAuth2AccessTokenMapper extends BaseMapperX selectListByUserIdAndUserType(Long userId, Integer userType) { + return selectList(new LambdaQueryWrapperX() + .eq(OAuth2AccessTokenDO::getUserId, userId) + .eq(OAuth2AccessTokenDO::getUserType, userType)); + } + default PageResult selectPage(OAuth2AccessTokenPageReqVO reqVO) { return selectPage(reqVO, new LambdaQueryWrapperX() .eqIfPresent(OAuth2AccessTokenDO::getUserId, reqVO.getUserId()) diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2RefreshTokenMapper.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2RefreshTokenMapper.java index bf91457..ff9a79c 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2RefreshTokenMapper.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/oauth2/OAuth2RefreshTokenMapper.java @@ -14,6 +14,12 @@ public interface OAuth2RefreshTokenMapper extends BaseMapperX() + .eq(OAuth2RefreshTokenDO::getUserId, userId) + .eq(OAuth2RefreshTokenDO::getUserType, userType)); + } + @TenantIgnore // 获取 token 的时候,需要忽略租户编号。原因是:一些场景下,可能不会传递 tenant-id 请求头,例如说文件上传、积木报表等等 default OAuth2RefreshTokenDO selectByRefreshToken(String refreshToken) { return selectOne(OAuth2RefreshTokenDO::getRefreshToken, refreshToken); diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/subaccount/SubAccountMapper.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/subaccount/SubAccountMapper.java new file mode 100644 index 0000000..e193183 --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/subaccount/SubAccountMapper.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.system.dal.mysql.subaccount; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.system.dal.dataobject.subaccount.SubAccountDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 后台子账号 Mapper + */ +@Mapper +public interface SubAccountMapper extends BaseMapperX { + + default List selectListByHolderUserId(Long holderUserId) { + return selectList(new LambdaQueryWrapperX() + .eq(SubAccountDO::getHolderUserId, holderUserId) + .orderByDesc(SubAccountDO::getId)); + } + + default SubAccountDO selectBySubUserId(Long subUserId) { + return selectOne(SubAccountDO::getSubUserId, subUserId); + } + +} diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenService.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenService.java index 977d935..29e975f 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenService.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenService.java @@ -69,6 +69,15 @@ public interface OAuth2TokenService { */ OAuth2AccessTokenDO removeAccessToken(String accessToken); + /** + * 移除指定用户的全部访问令牌与刷新令牌(踢下线) + * + * @param userId 用户编号 + * @param userType 用户类型 + * @return 删除的访问令牌数量 + */ + int removeAccessTokenByUserId(Long userId, Integer userType); + /** * 获得访问令牌分页 * diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java index 04bc744..98ae92e 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/oauth2/OAuth2TokenServiceImpl.java @@ -153,6 +153,25 @@ public class OAuth2TokenServiceImpl implements OAuth2TokenService { return accessTokenDO; } + @Override + @Transactional(rollbackFor = Exception.class) + public int removeAccessTokenByUserId(Long userId, Integer userType) { + if (userId == null || userType == null) { + return 0; + } + List accessTokenDOs = + oauth2AccessTokenMapper.selectListByUserIdAndUserType(userId, userType); + int removed = 0; + if (CollUtil.isNotEmpty(accessTokenDOs)) { + oauth2AccessTokenMapper.deleteByIds(convertSet(accessTokenDOs, OAuth2AccessTokenDO::getId)); + oauth2AccessTokenRedisDAO.deleteList(convertSet(accessTokenDOs, OAuth2AccessTokenDO::getAccessToken)); + removed = accessTokenDOs.size(); + } + // 清理可能残留的 refresh token(无对应 access 的场景) + oauth2RefreshTokenMapper.deleteByUserIdAndUserType(userId, userType); + return removed; + } + @Override public PageResult getAccessTokenPage(OAuth2AccessTokenPageReqVO reqVO) { return oauth2AccessTokenMapper.selectPage(reqVO); diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/subaccount/SubAccountService.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/subaccount/SubAccountService.java new file mode 100644 index 0000000..10717f5 --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/subaccount/SubAccountService.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.system.service.subaccount; + +import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountMenuRespVO; +import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountRespVO; +import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountSaveReqVO; + +import java.util.List; + +/** + * 后台子账号 Service 接口 + * + * AppHolder 在自己的权限范围内创建子账号,并分配子账号可见的菜单。 + */ +public interface SubAccountService { + + /** + * 获得当前登录账号(holder)的子账号列表 + * + * @return 子账号列表 + */ + List getSubAccountList(); + + /** + * 获得当前登录账号(holder)拥有的菜单(用于给子账号勾选) + * + * @return 菜单列表 + */ + List getHolderMenuList(); + + /** + * 创建子账号(用户名 + 昵称 + 密码 + 可见菜单) + * + * @param reqVO 创建信息(password 需要传) + * @return 子账号用户编号 + */ + Long createSubAccount(SubAccountSaveReqVO reqVO, String password); + + /** + * 修改子账号(昵称 + 可见菜单) + * + * @param userId 子账号用户编号 + * @param reqVO 修改信息 + */ + void updateSubAccount(Long userId, SubAccountSaveReqVO reqVO); + + /** + * 修改子账号状态(禁用后无法登录后台) + * + * @param userId 子账号用户编号 + * @param status 状态 + */ + void updateSubAccountStatus(Long userId, Integer status); + + /** + * 重置子账号密码 + * + * @param userId 子账号用户编号 + * @param password 新密码 + */ + void resetSubAccountPassword(Long userId, String password); + + /** + * 删除子账号(连同专属角色) + * + * @param userId 子账号用户编号 + */ + void deleteSubAccount(Long userId); + +} diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/subaccount/SubAccountServiceImpl.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/subaccount/SubAccountServiceImpl.java new file mode 100644 index 0000000..6a839ff --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/subaccount/SubAccountServiceImpl.java @@ -0,0 +1,230 @@ +package cn.iocoder.yudao.module.system.service.subaccount; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.RandomUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.system.controller.admin.permission.vo.role.RoleSaveReqVO; +import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountMenuRespVO; +import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountRespVO; +import cn.iocoder.yudao.module.system.controller.admin.subaccount.vo.SubAccountSaveReqVO; +import cn.iocoder.yudao.module.system.controller.admin.user.vo.user.UserSaveReqVO; +import cn.iocoder.yudao.module.system.dal.dataobject.permission.MenuDO; +import cn.iocoder.yudao.module.system.dal.dataobject.permission.RoleDO; +import cn.iocoder.yudao.module.system.dal.dataobject.subaccount.SubAccountDO; +import cn.iocoder.yudao.module.system.dal.dataobject.user.AdminUserDO; +import cn.iocoder.yudao.module.system.dal.mysql.subaccount.SubAccountMapper; +import cn.iocoder.yudao.module.system.enums.permission.RoleTypeEnum; +import cn.iocoder.yudao.module.system.service.permission.MenuService; +import cn.iocoder.yudao.module.system.service.permission.PermissionService; +import cn.iocoder.yudao.module.system.service.permission.RoleService; +import cn.iocoder.yudao.module.system.service.tenant.TenantService; +import cn.iocoder.yudao.module.system.service.user.AdminUserService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; +import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; + +/** + * 后台子账号 Service 实现类 + */ +@Service +@Validated +public class SubAccountServiceImpl implements SubAccountService { + + @Resource + private SubAccountMapper subAccountMapper; + @Resource + private AdminUserService userService; + @Resource + private RoleService roleService; + @Resource + private PermissionService permissionService; + @Resource + private MenuService menuService; + @Resource + private TenantService tenantService; + + @Override + public List getSubAccountList() { + Long holderUserId = getLoginUserId(); + List mappings = subAccountMapper.selectListByHolderUserId(holderUserId); + List result = new ArrayList<>(); + for (SubAccountDO mapping : mappings) { + AdminUserDO user = userService.getUser(mapping.getSubUserId()); + if (user == null) { // 用户已被删除,跳过 + continue; + } + SubAccountRespVO vo = BeanUtils.toBean(mapping, SubAccountRespVO.class); + vo.setUserId(user.getId()); + vo.setUsername(user.getUsername()); + vo.setNickname(user.getNickname()); + vo.setStatus(user.getStatus()); + vo.setLoginDate(user.getLoginDate()); + vo.setMenuIds(permissionService.getRoleMenuListByRoleId(mapping.getRoleId())); + result.add(vo); + } + return result; + } + + @Override + public List getHolderMenuList() { + Set holderMenuIds = getHolderMenuIds(getLoginUserId()); + if (CollUtil.isEmpty(holderMenuIds)) { + return new ArrayList<>(); + } + List menus = menuService.getMenuList(holderMenuIds); + menus = menuService.filterDisableMenus(menus); + List result = new ArrayList<>(); + for (MenuDO menu : menus) { + SubAccountMenuRespVO vo = new SubAccountMenuRespVO(); + vo.setId(menu.getId()); + vo.setParentId(menu.getParentId()); + vo.setName(menu.getName()); + vo.setType(menu.getType()); + vo.setSort(menu.getSort()); + result.add(vo); + } + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createSubAccount(SubAccountSaveReqVO reqVO, String password) { + Long holderUserId = getLoginUserId(); + // 1. 校验账号唯一 + if (userService.getUserByUsername(reqVO.getUsername()) != null) { + throw exception(USER_USERNAME_EXISTS); + } + // 2. 校验并过滤菜单:只能授予 holder 自己拥有的菜单 + Set menuIds = filterMenusByHolder(reqVO.getMenuIds(), holderUserId); + // 3. 创建子账号用户 + UserSaveReqVO userReqVO = new UserSaveReqVO(); + userReqVO.setUsername(reqVO.getUsername()); + userReqVO.setNickname(reqVO.getNickname()); + userReqVO.setPassword(password); + userReqVO.setRemark("AppHolder 子账号"); + Long subUserId = userService.createUser(userReqVO); + // 4. 创建专属角色并分配菜单 + Long roleId = createSubRole(subUserId, reqVO.getNickname()); + permissionService.assignRoleMenu(roleId, menuIds); + // 5. 绑定角色 + permissionService.assignUserRole(subUserId, CollUtil.newHashSet(roleId)); + // 6. 记录绑定关系 + SubAccountDO mapping = new SubAccountDO(); + mapping.setHolderUserId(holderUserId); + mapping.setSubUserId(subUserId); + mapping.setRoleId(roleId); + subAccountMapper.insert(mapping); + return subUserId; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSubAccount(Long userId, SubAccountSaveReqVO reqVO) { + SubAccountDO mapping = validateOwner(userId); + // 1. 更新昵称(username 不允许修改,密码走独立接口) + AdminUserDO user = userService.getUser(userId); + UserSaveReqVO userReqVO = new UserSaveReqVO(); + userReqVO.setId(userId); + userReqVO.setUsername(user.getUsername()); + userReqVO.setNickname(reqVO.getNickname()); + userService.updateUser(userReqVO); + // 2. 更新可见菜单 + Set menuIds = filterMenusByHolder(reqVO.getMenuIds(), mapping.getHolderUserId()); + permissionService.assignRoleMenu(mapping.getRoleId(), menuIds); + } + + @Override + public void updateSubAccountStatus(Long userId, Integer status) { + validateOwner(userId); + userService.updateUserStatus(userId, status); + } + + @Override + public void resetSubAccountPassword(Long userId, String password) { + validateOwner(userId); + userService.updateUserPassword(userId, password); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSubAccount(Long userId) { + SubAccountDO mapping = validateOwner(userId); + // 1. 删除用户 + userService.deleteUser(userId); + // 2. 删除专属角色 + roleService.deleteRole(mapping.getRoleId()); + // 3. 删除绑定关系 + subAccountMapper.deleteById(mapping.getId()); + } + + private Long createSubRole(Long subUserId, String nickname) { + RoleSaveReqVO roleReqVO = new RoleSaveReqVO(); + roleReqVO.setName("子账号-" + nickname); + roleReqVO.setCode("sub_acc_" + subUserId + "_" + RandomUtil.randomString(6).toLowerCase()); + roleReqVO.setSort(999); + roleReqVO.setStatus(CommonStatusEnum.ENABLE.getStatus()); + roleReqVO.setRemark("AppHolder 子账号专属角色"); + return roleService.createRole(roleReqVO, RoleTypeEnum.CUSTOM.getType()); + } + + /** + * 校验当前登录人是否为该子账号的管理者,并返回绑定关系 + */ + private SubAccountDO validateOwner(Long subUserId) { + SubAccountDO mapping = subAccountMapper.selectBySubUserId(subUserId); + if (mapping == null) { + throw exception(SUB_ACCOUNT_NOT_EXISTS); + } + if (!mapping.getHolderUserId().equals(getLoginUserId())) { + throw exception(SUB_ACCOUNT_NOT_OWNER); + } + return mapping; + } + + /** + * 获得 holder 拥有的菜单编号集合(其所有角色菜单的并集) + */ + private Set getHolderMenuIds(Long holderUserId) { + Set roleIds = permissionService.getUserRoleIdListByUserId(holderUserId); + if (CollUtil.isEmpty(roleIds)) { + return new HashSet<>(); + } + return permissionService.getRoleMenuListByRoleId(roleIds); + } + + /** + * 将请求的菜单过滤为 holder 自己拥有的菜单;若过滤后为空则报错 + */ + private Set filterMenusByHolder(Set requestMenuIds, Long holderUserId) { + Set holderMenuIds = getHolderMenuIds(holderUserId); + if (CollUtil.isEmpty(holderMenuIds)) { + throw exception(SUB_ACCOUNT_HOLDER_MENU_EMPTY); + } + Set menuIds = new HashSet<>(); + if (CollUtil.isNotEmpty(requestMenuIds)) { + tenantService.handleTenantMenu(menuIds0 -> { + for (Long menuId : requestMenuIds) { + if (holderMenuIds.contains(menuId) && menuIds0.contains(menuId)) { + menuIds.add(menuId); + } + } + }); + } + if (CollUtil.isEmpty(menuIds)) { + throw exception(SUB_ACCOUNT_MENU_EMPTY); + } + return menuIds; + } + +} diff --git a/yudao-server/src/main/resources/application-dev.yaml b/yudao-server/src/main/resources/application-dev.yaml index bced330..3513e00 100644 --- a/yudao-server/src/main/resources/application-dev.yaml +++ b/yudao-server/src/main/resources/application-dev.yaml @@ -50,12 +50,12 @@ spring: master: url: jdbc:mysql://139.224.25.207:3306/chat_v1?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例 username: chat_v1 - password: M4tWbKik2KHDcpZC + password: rfBzbFFdW4kepybz slave: # 模拟从库,可根据自己需要修改 # 模拟从库,可根据自己需要修改 lazy: true # 开启懒加载,保证启动速度 url: jdbc:mysql://139.224.25.207:3306/chat_v1?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例 username: chat_v1 - password: M4tWbKik2KHDcpZC + password: rfBzbFFdW4kepybz # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 redis: diff --git a/yudao-server/src/main/resources/application-local.yaml b/yudao-server/src/main/resources/application-local.yaml index 7738807..fc8e2fc 100644 --- a/yudao-server/src/main/resources/application-local.yaml +++ b/yudao-server/src/main/resources/application-local.yaml @@ -48,12 +48,12 @@ spring: master: url: jdbc:mysql://139.224.25.207:3306/chat_v1?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例 username: chat_v1 - password: M4tWbKik2KHDcpZC + password: BpJ2Ebt4zMPjBcCL slave: # 模拟从库,可根据自己需要修改 lazy: true # 开启懒加载,保证启动速度 url: jdbc:mysql://139.224.25.207:3306/chat_v1?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&nullCatalogMeansCurrent=true username: chat_v1 - password: M4tWbKik2KHDcpZC + password: BpJ2Ebt4zMPjBcCL # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 redis: @@ -193,6 +193,11 @@ wx: yudao: captcha: enable: false # 本地环境,暂时关闭图片验证码,方便登录等接口的测试; + member: + resp-encrypt: + enabled: true + # RSA PKCS#8 Base64(无 PEM 头尾),与 friend/assets/crypto/resp_enc_public.pem 配对 + private-key-base64: MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC1K3mUuyaWg6eEUd2Hw0lrUvvpT/JBZf5LZU/LJeCnv/1M1xs6XKBDSIPT5Jd3zbzCtWR4ejrsQu1ywsX/tJ15DWbKS/ESzAqEAbtIKNqhyr1LAydKt7uIeeu2c0pFEfxKjN2zruxEk94/Pyjxb9mvpcp4S5K29ncTqfYcPtSDjnmojIUwMmdjue1vcARacZ2FewX02qLbbK6omstA4ykFa7+lMWGkI+vYQJ0x26Ubj3Un6ydH9Rglm7tGjrk7gVvd3gRUL0QcofZxAMsCRVZx4WWJi+KGu61i8GSD1rKC3kVpBmJSt+J7SJXLkW9kuQ7ee/5+b2U9Mo3Jp09vbqQPAgMBAAECggEAHQndYH4I5touWRKoH/EozPeNoi1qxpm7A0m3Wvf0xN6Mkqrt1dJv66jtXGLI9m53IXxioL7yv9jQZ3MZum0wSm1f+u/mMZ2b1+HRK3VE6ebtpV7ZhuvhNrRYGDPthRmnZymiBYF1pF5RIrEUdnJ3oWRBCjwOxZ3Go1O8O3XEqVtBUKPpBgoUQqzFzyeck3HKNCZH62eIdTUQnyp7cU6wyj/9NwOerF7RbRRRFuayPzdPuHJVcye7/qX/4z8GlNLklP1QOXBg4Qj8x48doR8OaFwSrhoDjcjVQ+mdmoTPGcgLDtZSnTiWtY3Z8czovKIFLdtF6Rg0L8P+2ovBnlwMDQKBgQDaFxlnfVXu30SJJSwX6FgIdkRg/VjyuqAVo7T30qmRWENiyhfJ0MhMiiy57tkT0MwQOk3M/+Cp0ah4RTJSOanEr6kttTd3FzjVlbjNmJML6PjLw0UCGxBbdKLVqiqiGfz86MTDn5c+NluIMJ8oZqQT6algUuDbJ9K5hfsNk8bnuwKBgQDUqW/byjSl7Ph+SHOfji2THHvLRdKHQL+WijbOc7FiIsZuQZ5YyqIq7Rbed7dq38Nb9zo9hk4vd5FgcFvI/hPZUy3TngPYJfwvO+mvAdZGBCBY5V5waywhD+BRGrT7FVJ89iCQANPzCx4ficsKNiC7QTzUiCM3jbMD1aZIUDo9vQKBgQCiAtjnbU9iFVAHhaq80+hZ2OmxF2N1Zbg4exWbqE45X9UnVTPYw5WYbmkNcual9s0xHO7g5BDUdqBtQBJCzP1V1JycDOv4NzMMT54qdTZ/eYEhdZ0VFF/RYNL6YOAs7Ztnkc9h93NWTvEuOdhyHdWzyTf/C3Jou6uqEGLQ0j/HswKBgHC2SRoVxue2fFH6NoX3hkI1U4t+O0ICWEkCgl/Tw80+nysqzjFDXMYhmQBaNTWI2XMxyoV3XJMd7UhO5ObXe0+xHVXOpzz1jP6/43AlKhs77qUPFInC08XGM17pJ8j4GRswHDwWpD9pwhJ3Js0HuTtmLUZ7zSaRjLLYluQ22VeFAoGBAKz9Yd7/FfiaJ3RCG585E4WJg+BoO7uBw1xuTjKabt6gxTD3TcSAmnQEZrKSlmzxoF7ypdOUymJU46TzN5KD1MBkLADN+mr2Xk1TtawORFZk0KfZsXuVDwtSMXrmq5nj1Ul8noq8Azz8qTxxcuW4pdry0NF+Wfjny8ATyFDKY08w security: mock-enable: true pay: diff --git a/yudao-server/src/main/resources/application-prod.yaml b/yudao-server/src/main/resources/application-prod.yaml index a573d7e..840d3fe 100644 --- a/yudao-server/src/main/resources/application-prod.yaml +++ b/yudao-server/src/main/resources/application-prod.yaml @@ -47,12 +47,12 @@ spring: master: url: jdbc:mysql://127.0.0.1:3306/chat_v1?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true username: chat_v1 - password: M4tWbKik2KHDcpZC + password: rfBzbFFdW4kepybz slave: # 模拟从库,可根据自己需要修改 lazy: true # 开启懒加载,保证启动速度 url: jdbc:mysql://192.168.0.7:3306/chat_v1?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&nullCatalogMeansCurrent=true username: chat_v1 - password: M4tWbKik2KHDcpZC + password: rfBzbFFdW4kepybz # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 redis: host: 127.0.0.1 # 地址 @@ -189,6 +189,11 @@ wx: yudao: captcha: enable: true # 本地环境,暂时关闭图片验证码,方便登录等接口的测试; + member: + resp-encrypt: + enabled: true + # 生产建议用环境变量 YUDAO_RESP_ENCRYPT_PRIVATE_KEY;此处可临时写死与 local 同钥 + private-key-base64: ${YUDAO_RESP_ENCRYPT_PRIVATE_KEY:MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC1K3mUuyaWg6eEUd2Hw0lrUvvpT/JBZf5LZU/LJeCnv/1M1xs6XKBDSIPT5Jd3zbzCtWR4ejrsQu1ywsX/tJ15DWbKS/ESzAqEAbtIKNqhyr1LAydKt7uIeeu2c0pFEfxKjN2zruxEk94/Pyjxb9mvpcp4S5K29ncTqfYcPtSDjnmojIUwMmdjue1vcARacZ2FewX02qLbbK6omstA4ykFa7+lMWGkI+vYQJ0x26Ubj3Un6ydH9Rglm7tGjrk7gVvd3gRUL0QcofZxAMsCRVZx4WWJi+KGu61i8GSD1rKC3kVpBmJSt+J7SJXLkW9kuQ7ee/5+b2U9Mo3Jp09vbqQPAgMBAAECggEAHQndYH4I5touWRKoH/EozPeNoi1qxpm7A0m3Wvf0xN6Mkqrt1dJv66jtXGLI9m53IXxioL7yv9jQZ3MZum0wSm1f+u/mMZ2b1+HRK3VE6ebtpV7ZhuvhNrRYGDPthRmnZymiBYF1pF5RIrEUdnJ3oWRBCjwOxZ3Go1O8O3XEqVtBUKPpBgoUQqzFzyeck3HKNCZH62eIdTUQnyp7cU6wyj/9NwOerF7RbRRRFuayPzdPuHJVcye7/qX/4z8GlNLklP1QOXBg4Qj8x48doR8OaFwSrhoDjcjVQ+mdmoTPGcgLDtZSnTiWtY3Z8czovKIFLdtF6Rg0L8P+2ovBnlwMDQKBgQDaFxlnfVXu30SJJSwX6FgIdkRg/VjyuqAVo7T30qmRWENiyhfJ0MhMiiy57tkT0MwQOk3M/+Cp0ah4RTJSOanEr6kttTd3FzjVlbjNmJML6PjLw0UCGxBbdKLVqiqiGfz86MTDn5c+NluIMJ8oZqQT6algUuDbJ9K5hfsNk8bnuwKBgQDUqW/byjSl7Ph+SHOfji2THHvLRdKHQL+WijbOc7FiIsZuQZ5YyqIq7Rbed7dq38Nb9zo9hk4vd5FgcFvI/hPZUy3TngPYJfwvO+mvAdZGBCBY5V5waywhD+BRGrT7FVJ89iCQANPzCx4ficsKNiC7QTzUiCM3jbMD1aZIUDo9vQKBgQCiAtjnbU9iFVAHhaq80+hZ2OmxF2N1Zbg4exWbqE45X9UnVTPYw5WYbmkNcual9s0xHO7g5BDUdqBtQBJCzP1V1JycDOv4NzMMT54qdTZ/eYEhdZ0VFF/RYNL6YOAs7Ztnkc9h93NWTvEuOdhyHdWzyTf/C3Jou6uqEGLQ0j/HswKBgHC2SRoVxue2fFH6NoX3hkI1U4t+O0ICWEkCgl/Tw80+nysqzjFDXMYhmQBaNTWI2XMxyoV3XJMd7UhO5ObXe0+xHVXOpzz1jP6/43AlKhs77qUPFInC08XGM17pJ8j4GRswHDwWpD9pwhJ3Js0HuTtmLUZ7zSaRjLLYluQ22VeFAoGBAKz9Yd7/FfiaJ3RCG585E4WJg+BoO7uBw1xuTjKabt6gxTD3TcSAmnQEZrKSlmzxoF7ypdOUymJU46TzN5KD1MBkLADN+mr2Xk1TtawORFZk0KfZsXuVDwtSMXrmq5nj1Ul8noq8Azz8qTxxcuW4pdry0NF+Wfjny8ATyFDKY08w} security: mock-enable: true pay: diff --git a/yudao-server/src/main/resources/application.yaml b/yudao-server/src/main/resources/application.yaml index d9ea2a2..3d3d25c 100644 --- a/yudao-server/src/main/resources/application.yaml +++ b/yudao-server/src/main/resources/application.yaml @@ -110,7 +110,7 @@ aj: cache-number: 1000 # local 缓存的阈值,达到这个值,清除缓存 timing-clear: 180 # local定时清除过期缓存(单位秒),设置为0代表不执行 type: blockPuzzle # 验证码类型 default两种都实例化。 blockPuzzle 滑块拼图 clickWord 文字点选 - water-mark: 荷塘月色 # 右下角水印文字(我的水印),可使用 https://tool.chinaz.com/tools/unicode.aspx 中文转 Unicode,Linux 可能需要转 unicode + water-mark: 荷塘助手 # 右下角水印文字(我的水印),可使用 https://tool.chinaz.com/tools/unicode.aspx 中文转 Unicode,Linux 可能需要转 unicode interference-options: 0 # 滑动干扰项(0/1/2) req-frequency-limit-enable: false # 接口请求次数一分钟限制是否开启 true|false req-get-lock-limit: 5 # 验证失败 5 次,get接口锁定 @@ -235,6 +235,11 @@ yudao: info: version: 1.0.0 base-package: cn.iocoder.yudao + member: + resp-encrypt: + # local/prod 配置私钥后开启。白名单接口 data 强制加密(客户端可带 X-Resp-Ek,也可由响应信封 ek 字段解密) + enabled: true + private-key-base64: ${YUDAO_RESP_ENCRYPT_PRIVATE_KEY:} web: admin-ui: url: http://dashboard.yudao.iocoder.cn # Admin 管理后台 UI 的地址 @@ -361,6 +366,12 @@ yudao: key: pLXUGAwK5305 customer: E77DF18BE109F454A5CD319E44BF5177 +jpush: + enabled: ${JPUSH_ENABLED:true} + app-key: ${JPUSH_APP_KEY:d7773439aa8c579dc31a552f} + master-secret: ${JPUSH_MASTER_SECRET:a2ea6bb666a26ff1dd246ffa} + apns-production: ${JPUSH_APNS_PRODUCTION:true} + debug: false # 插件配置 TODO 芋艿:【IOT】需要处理下 pf4j: