From 9f22e6ccae7230cc84c65e9bb866169caa239e18 Mon Sep 17 00:00:00 2001 From: zhengchiliu <857493511@qq.com> Date: Fri, 11 Sep 2026 01:41:52 +0800 Subject: [PATCH] first commit --- .../plans/Go_后端全量代码审查_a0608b8a.md | 65 + .../OpenTelemetry三信号完整接入_fe5b200f.md | 120 + .../plans/README_补充业务流程章节_75c77671.md | 75 + .codebuddy/plans/api-v1层完整实现_e89004bd.md | 153 ++ ...assification-server-completion_ed2c7660.md | 153 ++ ...assification_server_python_run_77f6663b.md | 58 + ...fy-service-align-clothes-model_85d8b823.md | 176 ++ ...ce-align-clothes-model_e429d660(未完成).md | 127 + .../plans/dao-tracing-refactor_d4fb1e5c.md | 133 + .../fix-14-issues-except-8-and-15_dc316bf3.md | 116 + .../plans/fix-code-review-bugs_a7c936c2.md | 107 + .../plans/fix-remaining-issues_32b47301.md | 179 ++ .../plans/fix-second-review-bugs_4bdf0ac1.md | 157 ++ .../plans/matting-client-and-test_a3ad5696.md | 99 + .../matting_server_python_run_5105572d.md | 61 + .codebuddy/plans/model-call-retry_5868b00a.md | 87 + .../models_auto_download_fallback_2b158447.md | 84 + ...mysqlmodel_类型统一与_tag_精简_19fbaf95.md | 108 + ...ey_设计对齐_mysqlmodel_da53c011(未完成).md | 129 + .../plans/redismodel_从0全量设计_31a88700.md | 154 ++ .../service-span-and-async-trace_d93c7ebf.md | 100 + .../service-span-naming-dedupe_b2900987.md | 93 + .../plans/service层完整实现_3a88b729.md | 147 ++ ...witch-matting-to-birefnet-lite_dfe3ba51.md | 71 + .../tokenhub_与_model_对齐优化_b2391cd4.md | 91 + ...穿搭推荐_prompt_与输出字段对齐_020be2fa.md | 102 + .../修复XPay发货推送伪造漏洞_9bf015a6.md | 111 + ...®复XPay发货推送伪造漏洞_a31999c1(未完成).md | 81 + ...redisdao_语义与健壮性问题_P1_P2__832261d9.md | 111 + .../plans/修复中等与轻微问题6项_599caa55.md | 98 + .../修复中等与轻微问题8项_e909def3(未完成).md | 117 + .../plans/修复中高等级代码问题_7e510a15.md | 148 ++ .../plans/修复全部_39_项代码缺陷_f3b9019e.md | 228 ++ .../修复分类模型手表识别错误_0588c8ee.md | 83 + .../plans/修复支付业务逻辑问题_cb2e4aae.md | 93 + .../plans/允许免费额度配置为_0_dc363311.md | 92 + .../plans/全量代码二次审查报告_ceea1a15.md | 94 + ...�代码审查_定位业务与技术逻辑_bug_6b069bbb.md | 112 + .../plans/删除衣物时清理COS图片_39034235.md | 75 + .../plans/同步更新三个项目文档_5b365d72.md | 33 + ...º于_golang-migrate_的数据表迁移_50fc3756.md | 178 ++ .../完善UserDefaultQuota配置化_ca78d85f.md | 89 + .../plans/完善项目_README_文档_413d78c9.md | 64 + .../局部变量resp-req重命名为全称_419b3866.md | 127 + ...微信支付切换到官方wechatpay-go_910cf320.md | 151 ++ .../plans/扁平化迁移脚本目录结构_11e4276d.md | 106 + ...ˆ†_Stitch_提示词为分段可粘贴文档_fdfe8c49.md | 47 + .../plans/按层区分_DAO_span_命名_72d9748c.md | 124 + .../接入微信虚拟支付_全终端__8cbaa9ab.md | 149 ++ .../plans/收藏单条状态查询接口_352ab083.md | 73 + .../plans/数据库唯一索引全面补齐_0a7c6d59.md | 82 + .../plans/新用户注册赠送体验会员_9ba5542c.md | 149 ++ .../生成_Stitch_设计描述文档_fac81b47.md | 105 + .codebuddy/plans/生成前端接口文档_05e564d5.md | 136 + .../plans/生成数据库_ER_图_drawio_d0c6a093.md | 69 + .codebuddy/plans/用户头像上传接口_eb17021f.md | 133 + .../虚拟支付沙箱环境自动选择_bb981350.md | 124 + .../plans/衣柜分享-分享给我的_70d0547d.md | 147 ++ .../补充_README_接口调用逻辑文档_747f2d39.md | 70 + .../补齐_mysqldao_数据访问层_a1ecd74f.md | 245 ++ .../调整_COS_图片存储路径结构_5eea0fe3.md | 61 + .../通知列表补充_msg_type_筛选_9998d1d5.md | 86 + .../plans/配置全面去环境变量化_3cc28783.md | 83 + .../plans/配额流水事务一致性修复_9574ec66.md | 126 + ...³»改造_每日额度与VIP有效期_9b667d2b(未完成).md | 184 ++ ...»重构_每日额度_凭证有效期_广告激励_defdda63.md | 214 ++ .gitignore | 22 + README.md | 1567 +++++++++++ Stitch.md | 152 ++ cmd/migrate/main.go | 118 + configs/config.example.yaml | 125 + design/zt-closet-er.drawio | 179 ++ docs/代码审查报告-三次check.md | 178 ++ docs/代码审查报告-二次check.md | 207 ++ global/cos.go | 7 + global/mysql.go | 5 + global/otel.go | 17 + global/redis.go | 5 + global/setting.go | 22 + go.mod | 91 + go.sum | 284 ++ internal/dto/adreward.go | 16 + internal/dto/airecommend.go | 28 + internal/dto/closet.go | 52 + internal/dto/clothes.go | 91 + internal/dto/collect.go | 29 + internal/dto/common.go | 14 + internal/dto/goods.go | 34 + internal/dto/horoscope.go | 16 + internal/dto/like.go | 21 + internal/dto/notification.go | 39 + internal/dto/order.go | 86 + internal/dto/outfit.go | 83 + internal/dto/quota.go | 44 + internal/dto/user.go | 68 + internal/middleware/cors.go | 50 + internal/middleware/logger.go | 55 + internal/middleware/lzcwyjwt.go | 113 + internal/middleware/otel.go | 16 + internal/middleware/requestid.go | 37 + internal/mysqldao/auditlog.go | 79 + internal/mysqldao/benefit_grant_task.go | 121 + internal/mysqldao/closet.go | 201 ++ internal/mysqldao/clothes.go | 138 + internal/mysqldao/collect.go | 109 + internal/mysqldao/goods.go | 67 + internal/mysqldao/horoscope.go | 127 + internal/mysqldao/like.go | 102 + internal/mysqldao/notification.go | 114 + internal/mysqldao/order.go | 197 ++ internal/mysqldao/outfit.go | 216 ++ internal/mysqldao/quota.go | 502 ++++ internal/mysqldao/user.go | 176 ++ internal/mysqldao/vipflowlog.go | 65 + internal/mysqlmodel/aiflowlog.go | 41 + internal/mysqlmodel/auditlog.go | 25 + internal/mysqlmodel/benefit_grant_task.go | 22 + internal/mysqlmodel/closet.go | 35 + internal/mysqlmodel/clothes.go | 32 + internal/mysqlmodel/collect.go | 14 + internal/mysqlmodel/goods.go | 24 + internal/mysqlmodel/horoscope.go | 19 + internal/mysqlmodel/like.go | 14 + internal/mysqlmodel/model.go | 54 + internal/mysqlmodel/notification.go | 17 + internal/mysqlmodel/order.go | 34 + internal/mysqlmodel/outfit.go | 28 + internal/mysqlmodel/quota.go | 45 + internal/mysqlmodel/quotaflowlog.go | 23 + internal/mysqlmodel/refundflowlog.go | 22 + internal/mysqlmodel/user.go | 25 + internal/mysqlmodel/vipflowlog.go | 20 + internal/redisdao/auth.go | 122 + internal/redisdao/closet.go | 27 + internal/redisdao/collect.go | 79 + internal/redisdao/horoscope.go | 37 + internal/redisdao/like.go | 79 + internal/redisdao/lock.go | 44 + internal/redisdao/notification.go | 89 + internal/redisdao/outfit.go | 23 + internal/redisdao/quota.go | 186 ++ internal/redisdao/user.go | 71 + internal/redismodel/auth.go | 9 + internal/redismodel/closet.go | 4 + internal/redismodel/collect.go | 4 + internal/redismodel/horoscope.go | 4 + internal/redismodel/keys.go | 41 + internal/redismodel/like.go | 4 + internal/redismodel/lock.go | 13 + internal/redismodel/notification.go | 4 + internal/redismodel/outfit.go | 4 + internal/redismodel/quota.go | 9 + internal/redismodel/user.go | 4 + internal/routers/api/v1/adreward.go | 41 + internal/routers/api/v1/airecommend.go | 61 + internal/routers/api/v1/closet.go | 125 + internal/routers/api/v1/clothes.go | 158 ++ internal/routers/api/v1/collect.go | 83 + internal/routers/api/v1/goods.go | 51 + internal/routers/api/v1/horoscope.go | 35 + internal/routers/api/v1/like.go | 62 + internal/routers/api/v1/notification.go | 99 + internal/routers/api/v1/order.go | 181 ++ internal/routers/api/v1/outfit.go | 156 ++ internal/routers/api/v1/quota.go | 57 + internal/routers/api/v1/user.go | 119 + internal/routers/router.go | 133 + internal/service/adreward.go | 113 + internal/service/airecommend.go | 256 ++ internal/service/benefit_compensation.go | 112 + internal/service/closet.go | 366 +++ internal/service/clothes.go | 472 ++++ internal/service/collect.go | 183 ++ internal/service/constants.go | 213 ++ internal/service/goods.go | 84 + internal/service/horoscope.go | 172 ++ internal/service/like.go | 125 + internal/service/notification.go | 158 ++ internal/service/order.go | 747 ++++++ internal/service/order_expire.go | 46 + internal/service/outfit.go | 424 +++ internal/service/quota.go | 137 + internal/service/service.go | 465 ++++ internal/service/user.go | 417 +++ internal/service/virtualpay.go | 433 +++ main.go | 349 +++ pkg/app/response.go | 41 + pkg/classification/classification.go | 174 ++ pkg/classification/classification_test.go | 65 + pkg/cos/cosclient.go | 26 + pkg/errcode/clothes_code.go | 11 + pkg/errcode/common_code.go | 43 + pkg/errcode/errcode.go | 79 + pkg/errcode/goods_code.go | 13 + pkg/errcode/misc_code.go | 24 + pkg/errcode/order_code.go | 31 + pkg/errcode/outfit_code.go | 10 + pkg/errcode/quota_code.go | 19 + pkg/errcode/upload_code.go | 18 + pkg/errcode/user_code.go | 43 + pkg/lzcwyjwt/lzcwyjwt.go | 137 + pkg/matting/matting.go | 109 + pkg/matting/matting_test.go | 53 + pkg/migration/000001_create_users.down.sql | 1 + pkg/migration/000001_create_users.up.sql | 28 + pkg/migration/000002_create_clothes.down.sql | 1 + pkg/migration/000002_create_clothes.up.sql | 36 + pkg/migration/000003_create_outfits.down.sql | 2 + pkg/migration/000003_create_outfits.up.sql | 37 + .../000004_create_closet_shares.down.sql | 2 + .../000004_create_closet_shares.up.sql | 31 + .../000005_create_user_quotas.down.sql | 2 + .../000005_create_user_quotas.up.sql | 32 + .../000006_create_quota_flow_logs.down.sql | 1 + .../000006_create_quota_flow_logs.up.sql | 24 + pkg/migration/000007_create_goods.down.sql | 1 + pkg/migration/000007_create_goods.up.sql | 28 + pkg/migration/000008_create_orders.down.sql | 1 + pkg/migration/000008_create_orders.up.sql | 37 + .../000009_create_refund_flow_logs.down.sql | 1 + .../000009_create_refund_flow_logs.up.sql | 25 + .../000010_create_vip_flow_logs.down.sql | 1 + .../000010_create_vip_flow_logs.up.sql | 21 + ...000011_create_benefit_grant_tasks.down.sql | 1 + .../000011_create_benefit_grant_tasks.up.sql | 21 + .../000012_create_ai_flow_logs.down.sql | 2 + .../000012_create_ai_flow_logs.up.sql | 49 + .../000013_create_interactions.down.sql | 2 + .../000013_create_interactions.up.sql | 33 + .../000014_create_user_notifications.down.sql | 1 + .../000014_create_user_notifications.up.sql | 20 + .../000015_create_horoscopes.down.sql | 1 + pkg/migration/000015_create_horoscopes.up.sql | 20 + .../000016_create_audit_logs.down.sql | 2 + pkg/migration/000016_create_audit_logs.up.sql | 31 + .../000017_create_quota_grants.down.sql | 1 + .../000017_create_quota_grants.up.sql | 20 + ...018_create_closet_share_receivers.down.sql | 1 + ...00018_create_closet_share_receivers.up.sql | 15 + pkg/migration/migration.go | 163 ++ pkg/setting/section.go | 157 ++ pkg/setting/setting.go | 24 + pkg/tokenhub/client.go | 101 + pkg/tokenhub/fortune.go | 206 ++ pkg/tokenhub/outfit_recommend.go | 262 ++ pkg/tokenhub/scheduler.go | 170 ++ pkg/tracing/fanout.go | 68 + pkg/tracing/otel.go | 35 + pkg/tracing/provider.go | 154 ++ pkg/upload/upload.go | 217 ++ pkg/wechat/mini.go | 114 + pkg/wechat/pay.go | 351 +++ pkg/wechat/virtualpay.go | 553 ++++ pkg/wechat/virtualpay_test.go | 191 ++ resources/watch_gshock_test.jpg | Bin 0 -> 359301 bytes resources/微信图片_20260907235100_780_53.jpg | Bin 0 -> 61187 bytes resources/纸条AI衣橱小程序头像生成.jpg | Bin 0 -> 183683 bytes scripts/nginx/nginx.conf | 57 + thirdparty/classification_server/main.py | 611 +++++ .../classification_server/requirements.txt | 16 + thirdparty/matting_server/main.py | 96 + thirdparty/matting_server/requirements.txt | 23 + 接口文档.md | 2326 +++++++++++++++++ 263 files changed, 28935 insertions(+) create mode 100644 .codebuddy/plans/Go_后端全量代码审查_a0608b8a.md create mode 100644 .codebuddy/plans/OpenTelemetry三信号完整接入_fe5b200f.md create mode 100644 .codebuddy/plans/README_补充业务流程章节_75c77671.md create mode 100644 .codebuddy/plans/api-v1层完整实现_e89004bd.md create mode 100644 .codebuddy/plans/classification-server-completion_ed2c7660.md create mode 100644 .codebuddy/plans/classification_server_python_run_77f6663b.md create mode 100644 .codebuddy/plans/classify-service-align-clothes-model_85d8b823.md create mode 100644 .codebuddy/plans/classify-service-align-clothes-model_e429d660(未完成).md create mode 100644 .codebuddy/plans/dao-tracing-refactor_d4fb1e5c.md create mode 100644 .codebuddy/plans/fix-14-issues-except-8-and-15_dc316bf3.md create mode 100644 .codebuddy/plans/fix-code-review-bugs_a7c936c2.md create mode 100644 .codebuddy/plans/fix-remaining-issues_32b47301.md create mode 100644 .codebuddy/plans/fix-second-review-bugs_4bdf0ac1.md create mode 100644 .codebuddy/plans/matting-client-and-test_a3ad5696.md create mode 100644 .codebuddy/plans/matting_server_python_run_5105572d.md create mode 100644 .codebuddy/plans/model-call-retry_5868b00a.md create mode 100644 .codebuddy/plans/models_auto_download_fallback_2b158447.md create mode 100644 .codebuddy/plans/mysqlmodel_类型统一与_tag_精简_19fbaf95.md create mode 100644 .codebuddy/plans/redismodel_key_设计对齐_mysqlmodel_da53c011(未完成).md create mode 100644 .codebuddy/plans/redismodel_从0全量设计_31a88700.md create mode 100644 .codebuddy/plans/service-span-and-async-trace_d93c7ebf.md create mode 100644 .codebuddy/plans/service-span-naming-dedupe_b2900987.md create mode 100644 .codebuddy/plans/service层完整实现_3a88b729.md create mode 100644 .codebuddy/plans/switch-matting-to-birefnet-lite_dfe3ba51.md create mode 100644 .codebuddy/plans/tokenhub_与_model_对齐优化_b2391cd4.md create mode 100644 .codebuddy/plans/tokenhub_穿搭推荐_prompt_与输出字段对齐_020be2fa.md create mode 100644 .codebuddy/plans/修复XPay发货推送伪造漏洞_9bf015a6.md create mode 100644 .codebuddy/plans/修复XPay发货推送伪造漏洞_a31999c1(未完成).md create mode 100644 .codebuddy/plans/修复_redismodel_redisdao_语义与健壮性问题_P1_P2__832261d9.md create mode 100644 .codebuddy/plans/修复中等与轻微问题6项_599caa55.md create mode 100644 .codebuddy/plans/修复中等与轻微问题8项_e909def3(未完成).md create mode 100644 .codebuddy/plans/修复中高等级代码问题_7e510a15.md create mode 100644 .codebuddy/plans/修复全部_39_项代码缺陷_f3b9019e.md create mode 100644 .codebuddy/plans/修复分类模型手表识别错误_0588c8ee.md create mode 100644 .codebuddy/plans/修复支付业务逻辑问题_cb2e4aae.md create mode 100644 .codebuddy/plans/允许免费额度配置为_0_dc363311.md create mode 100644 .codebuddy/plans/全量代码二次审查报告_ceea1a15.md create mode 100644 .codebuddy/plans/全量代码审查_定位业务与技术逻辑_bug_6b069bbb.md create mode 100644 .codebuddy/plans/删除衣物时清理COS图片_39034235.md create mode 100644 .codebuddy/plans/同步更新三个项目文档_5b365d72.md create mode 100644 .codebuddy/plans/基于_golang-migrate_的数据表迁移_50fc3756.md create mode 100644 .codebuddy/plans/完善UserDefaultQuota配置化_ca78d85f.md create mode 100644 .codebuddy/plans/完善项目_README_文档_413d78c9.md create mode 100644 .codebuddy/plans/局部变量resp-req重命名为全称_419b3866.md create mode 100644 .codebuddy/plans/微信支付切换到官方wechatpay-go_910cf320.md create mode 100644 .codebuddy/plans/扁平化迁移脚本目录结构_11e4276d.md create mode 100644 .codebuddy/plans/拆分_Stitch_提示词为分段可粘贴文档_fdfe8c49.md create mode 100644 .codebuddy/plans/按层区分_DAO_span_命名_72d9748c.md create mode 100644 .codebuddy/plans/接入微信虚拟支付_全终端__8cbaa9ab.md create mode 100644 .codebuddy/plans/收藏单条状态查询接口_352ab083.md create mode 100644 .codebuddy/plans/数据库唯一索引全面补齐_0a7c6d59.md create mode 100644 .codebuddy/plans/新用户注册赠送体验会员_9ba5542c.md create mode 100644 .codebuddy/plans/生成_Stitch_设计描述文档_fac81b47.md create mode 100644 .codebuddy/plans/生成前端接口文档_05e564d5.md create mode 100644 .codebuddy/plans/生成数据库_ER_图_drawio_d0c6a093.md create mode 100644 .codebuddy/plans/用户头像上传接口_eb17021f.md create mode 100644 .codebuddy/plans/虚拟支付沙箱环境自动选择_bb981350.md create mode 100644 .codebuddy/plans/衣柜分享-分享给我的_70d0547d.md create mode 100644 .codebuddy/plans/补充_README_接口调用逻辑文档_747f2d39.md create mode 100644 .codebuddy/plans/补齐_mysqldao_数据访问层_a1ecd74f.md create mode 100644 .codebuddy/plans/调整_COS_图片存储路径结构_5eea0fe3.md create mode 100644 .codebuddy/plans/通知列表补充_msg_type_筛选_9998d1d5.md create mode 100644 .codebuddy/plans/配置全面去环境变量化_3cc28783.md create mode 100644 .codebuddy/plans/配额流水事务一致性修复_9574ec66.md create mode 100644 .codebuddy/plans/额度体系改造_每日额度与VIP有效期_9b667d2b(未完成).md create mode 100644 .codebuddy/plans/额度体系重构_每日额度_凭证有效期_广告激励_defdda63.md create mode 100644 .gitignore create mode 100644 README.md create mode 100644 Stitch.md create mode 100644 cmd/migrate/main.go create mode 100644 configs/config.example.yaml create mode 100644 design/zt-closet-er.drawio create mode 100644 docs/代码审查报告-三次check.md create mode 100644 docs/代码审查报告-二次check.md create mode 100644 global/cos.go create mode 100644 global/mysql.go create mode 100644 global/otel.go create mode 100644 global/redis.go create mode 100644 global/setting.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/dto/adreward.go create mode 100644 internal/dto/airecommend.go create mode 100644 internal/dto/closet.go create mode 100644 internal/dto/clothes.go create mode 100644 internal/dto/collect.go create mode 100644 internal/dto/common.go create mode 100644 internal/dto/goods.go create mode 100644 internal/dto/horoscope.go create mode 100644 internal/dto/like.go create mode 100644 internal/dto/notification.go create mode 100644 internal/dto/order.go create mode 100644 internal/dto/outfit.go create mode 100644 internal/dto/quota.go create mode 100644 internal/dto/user.go create mode 100644 internal/middleware/cors.go create mode 100644 internal/middleware/logger.go create mode 100644 internal/middleware/lzcwyjwt.go create mode 100644 internal/middleware/otel.go create mode 100644 internal/middleware/requestid.go create mode 100644 internal/mysqldao/auditlog.go create mode 100644 internal/mysqldao/benefit_grant_task.go create mode 100644 internal/mysqldao/closet.go create mode 100644 internal/mysqldao/clothes.go create mode 100644 internal/mysqldao/collect.go create mode 100644 internal/mysqldao/goods.go create mode 100644 internal/mysqldao/horoscope.go create mode 100644 internal/mysqldao/like.go create mode 100644 internal/mysqldao/notification.go create mode 100644 internal/mysqldao/order.go create mode 100644 internal/mysqldao/outfit.go create mode 100644 internal/mysqldao/quota.go create mode 100644 internal/mysqldao/user.go create mode 100644 internal/mysqldao/vipflowlog.go create mode 100644 internal/mysqlmodel/aiflowlog.go create mode 100644 internal/mysqlmodel/auditlog.go create mode 100644 internal/mysqlmodel/benefit_grant_task.go create mode 100644 internal/mysqlmodel/closet.go create mode 100644 internal/mysqlmodel/clothes.go create mode 100644 internal/mysqlmodel/collect.go create mode 100644 internal/mysqlmodel/goods.go create mode 100644 internal/mysqlmodel/horoscope.go create mode 100644 internal/mysqlmodel/like.go create mode 100644 internal/mysqlmodel/model.go create mode 100644 internal/mysqlmodel/notification.go create mode 100644 internal/mysqlmodel/order.go create mode 100644 internal/mysqlmodel/outfit.go create mode 100644 internal/mysqlmodel/quota.go create mode 100644 internal/mysqlmodel/quotaflowlog.go create mode 100644 internal/mysqlmodel/refundflowlog.go create mode 100644 internal/mysqlmodel/user.go create mode 100644 internal/mysqlmodel/vipflowlog.go create mode 100644 internal/redisdao/auth.go create mode 100644 internal/redisdao/closet.go create mode 100644 internal/redisdao/collect.go create mode 100644 internal/redisdao/horoscope.go create mode 100644 internal/redisdao/like.go create mode 100644 internal/redisdao/lock.go create mode 100644 internal/redisdao/notification.go create mode 100644 internal/redisdao/outfit.go create mode 100644 internal/redisdao/quota.go create mode 100644 internal/redisdao/user.go create mode 100644 internal/redismodel/auth.go create mode 100644 internal/redismodel/closet.go create mode 100644 internal/redismodel/collect.go create mode 100644 internal/redismodel/horoscope.go create mode 100644 internal/redismodel/keys.go create mode 100644 internal/redismodel/like.go create mode 100644 internal/redismodel/lock.go create mode 100644 internal/redismodel/notification.go create mode 100644 internal/redismodel/outfit.go create mode 100644 internal/redismodel/quota.go create mode 100644 internal/redismodel/user.go create mode 100644 internal/routers/api/v1/adreward.go create mode 100644 internal/routers/api/v1/airecommend.go create mode 100644 internal/routers/api/v1/closet.go create mode 100644 internal/routers/api/v1/clothes.go create mode 100644 internal/routers/api/v1/collect.go create mode 100644 internal/routers/api/v1/goods.go create mode 100644 internal/routers/api/v1/horoscope.go create mode 100644 internal/routers/api/v1/like.go create mode 100644 internal/routers/api/v1/notification.go create mode 100644 internal/routers/api/v1/order.go create mode 100644 internal/routers/api/v1/outfit.go create mode 100644 internal/routers/api/v1/quota.go create mode 100644 internal/routers/api/v1/user.go create mode 100644 internal/routers/router.go create mode 100644 internal/service/adreward.go create mode 100644 internal/service/airecommend.go create mode 100644 internal/service/benefit_compensation.go create mode 100644 internal/service/closet.go create mode 100644 internal/service/clothes.go create mode 100644 internal/service/collect.go create mode 100644 internal/service/constants.go create mode 100644 internal/service/goods.go create mode 100644 internal/service/horoscope.go create mode 100644 internal/service/like.go create mode 100644 internal/service/notification.go create mode 100644 internal/service/order.go create mode 100644 internal/service/order_expire.go create mode 100644 internal/service/outfit.go create mode 100644 internal/service/quota.go create mode 100644 internal/service/service.go create mode 100644 internal/service/user.go create mode 100644 internal/service/virtualpay.go create mode 100644 main.go create mode 100644 pkg/app/response.go create mode 100644 pkg/classification/classification.go create mode 100644 pkg/classification/classification_test.go create mode 100644 pkg/cos/cosclient.go create mode 100644 pkg/errcode/clothes_code.go create mode 100644 pkg/errcode/common_code.go create mode 100644 pkg/errcode/errcode.go create mode 100644 pkg/errcode/goods_code.go create mode 100644 pkg/errcode/misc_code.go create mode 100644 pkg/errcode/order_code.go create mode 100644 pkg/errcode/outfit_code.go create mode 100644 pkg/errcode/quota_code.go create mode 100644 pkg/errcode/upload_code.go create mode 100644 pkg/errcode/user_code.go create mode 100644 pkg/lzcwyjwt/lzcwyjwt.go create mode 100644 pkg/matting/matting.go create mode 100644 pkg/matting/matting_test.go create mode 100644 pkg/migration/000001_create_users.down.sql create mode 100644 pkg/migration/000001_create_users.up.sql create mode 100644 pkg/migration/000002_create_clothes.down.sql create mode 100644 pkg/migration/000002_create_clothes.up.sql create mode 100644 pkg/migration/000003_create_outfits.down.sql create mode 100644 pkg/migration/000003_create_outfits.up.sql create mode 100644 pkg/migration/000004_create_closet_shares.down.sql create mode 100644 pkg/migration/000004_create_closet_shares.up.sql create mode 100644 pkg/migration/000005_create_user_quotas.down.sql create mode 100644 pkg/migration/000005_create_user_quotas.up.sql create mode 100644 pkg/migration/000006_create_quota_flow_logs.down.sql create mode 100644 pkg/migration/000006_create_quota_flow_logs.up.sql create mode 100644 pkg/migration/000007_create_goods.down.sql create mode 100644 pkg/migration/000007_create_goods.up.sql create mode 100644 pkg/migration/000008_create_orders.down.sql create mode 100644 pkg/migration/000008_create_orders.up.sql create mode 100644 pkg/migration/000009_create_refund_flow_logs.down.sql create mode 100644 pkg/migration/000009_create_refund_flow_logs.up.sql create mode 100644 pkg/migration/000010_create_vip_flow_logs.down.sql create mode 100644 pkg/migration/000010_create_vip_flow_logs.up.sql create mode 100644 pkg/migration/000011_create_benefit_grant_tasks.down.sql create mode 100644 pkg/migration/000011_create_benefit_grant_tasks.up.sql create mode 100644 pkg/migration/000012_create_ai_flow_logs.down.sql create mode 100644 pkg/migration/000012_create_ai_flow_logs.up.sql create mode 100644 pkg/migration/000013_create_interactions.down.sql create mode 100644 pkg/migration/000013_create_interactions.up.sql create mode 100644 pkg/migration/000014_create_user_notifications.down.sql create mode 100644 pkg/migration/000014_create_user_notifications.up.sql create mode 100644 pkg/migration/000015_create_horoscopes.down.sql create mode 100644 pkg/migration/000015_create_horoscopes.up.sql create mode 100644 pkg/migration/000016_create_audit_logs.down.sql create mode 100644 pkg/migration/000016_create_audit_logs.up.sql create mode 100644 pkg/migration/000017_create_quota_grants.down.sql create mode 100644 pkg/migration/000017_create_quota_grants.up.sql create mode 100644 pkg/migration/000018_create_closet_share_receivers.down.sql create mode 100644 pkg/migration/000018_create_closet_share_receivers.up.sql create mode 100644 pkg/migration/migration.go create mode 100644 pkg/setting/section.go create mode 100644 pkg/setting/setting.go create mode 100644 pkg/tokenhub/client.go create mode 100644 pkg/tokenhub/fortune.go create mode 100644 pkg/tokenhub/outfit_recommend.go create mode 100644 pkg/tokenhub/scheduler.go create mode 100644 pkg/tracing/fanout.go create mode 100644 pkg/tracing/otel.go create mode 100644 pkg/tracing/provider.go create mode 100644 pkg/upload/upload.go create mode 100644 pkg/wechat/mini.go create mode 100644 pkg/wechat/pay.go create mode 100644 pkg/wechat/virtualpay.go create mode 100644 pkg/wechat/virtualpay_test.go create mode 100644 resources/watch_gshock_test.jpg create mode 100644 resources/微信图片_20260907235100_780_53.jpg create mode 100644 resources/纸条AI衣橱小程序头像生成.jpg create mode 100644 scripts/nginx/nginx.conf create mode 100644 thirdparty/classification_server/main.py create mode 100644 thirdparty/classification_server/requirements.txt create mode 100644 thirdparty/matting_server/main.py create mode 100644 thirdparty/matting_server/requirements.txt create mode 100644 接口文档.md diff --git a/.codebuddy/plans/Go_后端全量代码审查_a0608b8a.md b/.codebuddy/plans/Go_后端全量代码审查_a0608b8a.md new file mode 100644 index 0000000..63e8830 --- /dev/null +++ b/.codebuddy/plans/Go_后端全量代码审查_a0608b8a.md @@ -0,0 +1,65 @@ +--- +name: Go 后端全量代码审查 +overview: 对 zt-closet-backend 的 Go 后端代码(internal / pkg / global / main.go / cmd)进行全量业务逻辑与技术逻辑审查,产出一份 Markdown 审查报告文件,不改动任何业务代码。 +todos: + - id: baseline-review + content: 用 [subagent:code-explorer] 建立模块基线并核对上轮修复现状 + status: completed + - id: review-trade + content: 用 [skill:lsp-code-analysis] 审查交易与额度域(订单/虚拟支付/额度/补偿/商品) + status: completed + dependencies: + - baseline-review + - id: review-content + content: 审查内容与社区域(衣物/穿搭/分享/互动/通知/AI推荐/用户) + status: completed + dependencies: + - baseline-review + - id: review-crosscutting + content: 审查横切技术面(安全/并发/事务/错误处理/资源/基础设施) + status: completed + dependencies: + - baseline-review + - id: generate-report + content: 复核上轮报告并生成审查报告 docs/代码审查报告-三次check.md + status: completed + dependencies: + - review-trade + - review-content + - review-crosscutting +--- + +## 用户需求 + +对 zt-closet-backend 全量 Go 后端进行只读代码审查,识别业务逻辑与技术逻辑问题,并生成 Markdown 审查报告文件。 + +## 审查范围 + +- 覆盖 `internal/`、`pkg/`、`global/`、`main.go`、`cmd/`(不含 `thirdparty/` Python 服务)。 +- 只出报告,不修改任何业务代码,仅新增报告文件。 + +## 审查维度 + +- 业务逻辑:状态机流转、额度扣减/回退一致性、幂等性、并发竞态、金额与单位、边界条件、补偿机制闭环。 +- 技术逻辑:安全(SQL 注入、越权 AuthZ、SSRF、XSS、密钥泄露、反序列化)、事务一致性、并发安全、错误处理、资源泄漏、超时控制、分页边界、日志脱敏。 + +## 交付物 + +- 新报告文件 `docs/代码审查报告-三次check.md`。 +- 问题按「严重/中等/轻微」分级,每条包含文件+行号/函数定位、风险说明、修复建议。 +- 复核上一轮报告 `docs/代码审查报告-二次check.md` 中各项的当前修复状态。 +- 结论须基于真实代码,不确定处标注「待确认」。 + +## 推荐扩展 + +### SubAgent + +- **code-explorer** +- 用途:跨目录建立模块与文件清单,定位上一轮报告涉及文件及当前实现状态。 +- 预期结果:产出各业务域关键文件/函数清单,支撑后续分域审查。 + +### Skill + +- **lsp-code-analysis** +- 用途:追踪额度扣减/回退、订单状态流转、权益发放等关键符号的定义与引用链。 +- 预期结果:明确关键调用链与全部调用点,验证并发与幂等路径。 \ No newline at end of file diff --git a/.codebuddy/plans/OpenTelemetry三信号完整接入_fe5b200f.md b/.codebuddy/plans/OpenTelemetry三信号完整接入_fe5b200f.md new file mode 100644 index 0000000..0a451b5 --- /dev/null +++ b/.codebuddy/plans/OpenTelemetry三信号完整接入_fe5b200f.md @@ -0,0 +1,120 @@ +--- +name: OpenTelemetry三信号完整接入 +overview: 实现 OpenTelemetry Traces/Metrics/Logs 三大信号的完整初始化与 OTLP gRPC 导出,补齐当前 main.go 引用的未实现桩(NewOTelProvider/OTelShutdown),保持本地日志不变的同时新增 OTel 导出,并加优雅关闭以刷新数据。 +todos: + - id: deps-and-globals + content: 补齐 otel sdk/exporter/otelslog/runtime 依赖(go mod tidy),在 global/otel.go 新增 OTelShutdown 与 Meter 变量,并对齐 config.yaml 与 config.example.yaml 的 SamplerRatio 键名 + status: completed + - id: provider + content: 实现 pkg/tracing/provider.go 的 NewOTelProvider:构建 Resource 与 Tracer/Meter/Logger 三 Provider、OTLP gRPC 导出、runtime 指标、采样与 Enabled/Insecure 开关、no-op 降级、聚合 Shutdown + status: completed + dependencies: + - deps-and-globals + - id: log-fanout + content: 实现 pkg/tracing/fanout.go 扇出 slog.Handler,将日志同时输出到本地 JSON(保留 OtelContextHandler)与 otelslog bridge + status: completed + dependencies: + - provider + - id: wire-main + content: 修改 main.go:修正 setupOTel 符号与错误降级、调整 init 顺序使 logger 桥接 LoggerProvider、setupSlogLogger 接入扇出 handler、main() 增加 SIGINT/SIGTERM 监听与 server.Shutdown + OTelShutdown 优雅关闭 + status: completed + dependencies: + - provider + - log-fanout + - id: verify + content: 执行 go mod tidy 与 go build/go vet 全量验证,smoke 验证 Enabled=false 及采集器不可用时不 panic 并正常启动,清理临时验证文件 + status: completed + dependencies: + - wire-main +--- + +## 产品概述 + +为「衣柜/穿搭」后端补齐 OpenTelemetry 三大信号(Traces / Metrics / Logs)的完整初始化与 OTLP gRPC 导出,把当前"只埋点、未导出"的半接入状态升级为可真正观测的完整链路,并修复因待实现桩导致的编译失败问题。 + +## 核心功能 + +- **Traces(链路追踪)**:注册真实 TracerProvider(Resource + OTLP gRPC 批量导出 + 采样率),使全项目已有的 span 与 gin 请求 span 真正导出。 +- **Metrics(指标)**:注册 MeterProvider(OTLP gRPC 周期导出),采集 Go runtime 指标,并预留自定义指标入口。 +- **Logs(日志)**:新增 OTel 日志导出(otelslog 桥接),同时保留现有本地 JSON 日志(文件+stdout,含 trace_id/span_id 注入)。 +- **优雅关闭**:进程收到退出信号时先关闭 HTTP 服务,再刷新并关闭三个 Provider,避免丢失缓冲数据。 +- **开关与降级**:尊重配置 `Enabled` 开关;未启用或采集器不可用时降级为 no-op,服务照常启动、业务不受影响、不 panic。 + +## 技术栈 + +- 语言/框架:Go 1.27 + Gin(沿用现有项目),OpenTelemetry-Go SDK。 +- 基准版本对齐现有 `go.opentelemetry.io/otel v1.46.0`;SDK trace/metric 用对应 v1.46.x,log 相关为配套 v0.x,由 `go mod tidy` 解析。 +- 传输:OTLP over gRPC,导出到配置的 `ExporterEndpoint`(如 `127.0.0.1:4317`)。 + +## 实现思路 + +在 `pkg/tracing` 新增统一初始化入口 `NewOTelProvider`,一次性构建 Resource 与三个 SDK Provider(Tracer/Meter/Logger),设置到 otel 全局,并返回聚合的 shutdown 函数;`main.go` 修正现有待实现桩(`setupOTel` 引用的未定义符号)、修正 init 顺序与错误降级、补充信号监听与优雅关闭。业务层 100+ 处 `global.Tracer.Start(...)` 与 `otelgin.Middleware(...)` 无需改动,Provider 注册后立即生效。 + +### 关键技术决策 + +- **统一入口 + 聚合 Shutdown**:单一 `NewOTelProvider` 返回 `(trace.Tracer, func(ctx) error, error)`,内部聚合三个 Provider 的 Shutdown,`main` 只持有一个 `global.OTelShutdown`,降低接线复杂度与遗漏 flush 风险。 +- **日志扇出(fanout)而非替换**:保留现有本地 JSON handler(含 `OtelContextHandler` 的 trace 注入),叠加 `otelslog` bridge handler,用一个极简自研 `slog.Handler` 扇出到两者,避免引入第三方 slog-multi 依赖,且不破坏现有日志落盘。 +- **降级优先,保障可用性**:`Enabled=false` 或 exporter 建立失败时,`global.Tracer` 使用 no-op tracer(业务层 `Tracer.Start` 安全非 nil),`OTelShutdown` 为空实现;`Insecure=true` 用非 TLS gRPC(内网采集器语义,不额外放开外网)。 +- **批处理与性能**:Trace 用 BatchSpanProcessor、Metric 用 PeriodicReader、Log 用 BatchProcessor,均为异步批量导出,热路径开销可控;采样由 `SamplerRatio` 控制(`ParentBased(TraceIDRatioBased)`),对 <=0 做默认兜底 1.0。 + +## 实现要点(防回归) + +- **编译阻断修复**:`main.go` 现引用未定义符号 `tracing.NewOTelProvider`、`global.OTelShutdown`、`global.OTelSetting`(真实名为 `global.OtelSetting`)。需实现前两者、并把 setupOTel 内改回 `global.OtelSetting`,使项目恢复可编译。 +- **配置键名对齐 bug**:`config.example.yaml` 使用 `SampleRatio`,而 `setting.OtelSettingS` 字段为 `SamplerRatio`(拼写不一致 → viper 读不到 → 采样率 0 → 不采样)。统一为 `SamplerRatio`(改 yaml 键),并在代码对 ratio<=0 兜底 1.0。同时 `configs/config.yaml`(实际运行配置)若存在同样键名需一并对齐。 +- **init 顺序**:需保证 setupOTel 在 setupSlogLogger 之前完成 LoggerProvider 构建,slog 扇出才能拿到 bridge;或 setupSlogLogger 在 provider 就绪后重建默认 logger。 +- **错误处理**:setupOTel 失败时告警不 panic(保持服务可启动),并降级 no-op。 +- **优雅关闭**:`main()` 当前直接 `ListenAndServe`,需改为监听 SIGINT/SIGTERM,先 `http.Server.Shutdown(ctx)` 再 `global.OTelShutdown(ctx)`,设置合理超时(如 5s)避免卡死。 +- 复用现有 `internal/middleware/otel.go`(otelgin)与 `pkg/tracing/otel.go`(OtelContextHandler),不重复造轮子。 + +## 架构与数据流 + +```mermaid +flowchart TD + A[HTTP 请求] --> B[otelgin 中间件 server span] + B --> C[handler/service/dao
global.Tracer.Start 子 span] + C --> D[slog 扇出 Handler] + D --> E[本地 JSON 文件+stdout
OtelContextHandler 注入 trace_id] + D --> F[otelslog bridge] + C --> G[TracerProvider
BatchSpanProcessor] + C --> H[MeterProvider
PeriodicReader + runtime 指标] + F --> I[LoggerProvider
BatchProcessor] + G --> J[(OTLP gRPC Collector
ExporterEndpoint)] + H --> J + I --> J +``` + +## 目录结构 + +``` +zt-closet-backend/ +├── go.mod / go.sum # [MODIFY] go mod tidy 补 otel sdk / sdk-metric / sdk-log / resource+semconv / +│ # exporters/otlp/{otlptracegrpc, otlpmetricgrpc, otlploggrpc} / +│ # contrib/instrumentation/runtime / contrib/bridges/otelslog / log/global +├── global/ +│ └── otel.go # [MODIFY] 保留 Tracer;新增 OTelShutdown func(context.Context) error、 +│ # Meter metric.Meter(可选自定义指标入口)、LoggerProvider(可选) +├── pkg/ +│ ├── setting/section.go # [MODIFY] 若选择改字段则同步;默认优先改 yaml 对齐字段(本方案改 yaml,section 不动) +│ └── tracing/ +│ ├── otel.go # [KEEP] OtelContextHandler 保留 +│ ├── provider.go # [NEW] NewOTelProvider:构建 Resource + Tracer/Meter/Logger Provider + +│ │ # OTLP gRPC 导出 + runtime 指标 + 采样 + Enabled/Insecure 开关 + +│ │ # no-op 降级 + 聚合 shutdown +│ └── fanout.go # [NEW] fanoutHandler:极简 slog.Handler,将日志同时发给本地 handler 与 otelslog bridge +├── internal/middleware/ +│ └── otel.go # [KEEP] otelgin 中间件,无需改 +├── configs/ +│ ├── config.example.yaml # [MODIFY] OTel 段键名对齐 SamplerRatio,校正注释 +│ └── config.yaml # [MODIFY] 若存在同名键,一并对齐 SamplerRatio(运行时实际读取) +└── main.go # [MODIFY] 修正 setupOTel 符号与错误降级;调整 init 顺序; + # setupSlogLogger 使用扇出 handler;main() 加信号监听 + 优雅关闭 +``` + +## 关键代码结构(接口约定) + +``` +// pkg/tracing/provider.go —— 统一初始化入口 +// 返回:全局 Tracer、聚合 Shutdown(flush 三个 Provider)、错误 +// Enabled=false 或初始化失败时返回 no-op Tracer + 安全空 Shutdown +func NewOTelProvider(cfg *setting.OtelSettingS) (trace.Tracer, func(context.Context) error, error) +``` \ No newline at end of file diff --git a/.codebuddy/plans/README_补充业务流程章节_75c77671.md b/.codebuddy/plans/README_补充业务流程章节_75c77671.md new file mode 100644 index 0000000..7808f15 --- /dev/null +++ b/.codebuddy/plans/README_补充业务流程章节_75c77671.md @@ -0,0 +1,75 @@ +--- +name: README 补充业务流程章节 +overview: 在 README 中新增"业务流程"章节,以产品级业务视角梳理端到端业务闭环(用户旅程、衣物生命周期、穿搭与社区、衣柜分享、交易与权益、AI 推荐、通知触发),用 mermaid 流程图 + 状态机 + 业务规则描述,与已有的"核心业务调用流程"(接口时序视角)互补不重复。 +todos: + - id: explore-business-rules + content: 使用 [subagent:code-explorer] 读取 service 层与常量定义,产出 7 个业务域的规则事实清单 + status: completed + - id: write-flow-overview-lifecycle + content: 在 README"配置说明"后插入"## 业务流程"章节:总览图、衣物生命周期、穿搭与社区、衣柜分享四节 + status: completed + dependencies: + - explore-business-rules + - id: write-flow-trade-ai-notify + content: 继续撰写交易与权益、AI 推荐、通知触发规则三节,并与"核心业务调用流程"互链 + status: completed + dependencies: + - write-flow-overview-lifecycle + - id: proofread-business-flows + content: 全文校对:枚举与周期和代码一致、mermaid 语法与层级正确、既有章节未被破坏 + status: completed + dependencies: + - write-flow-trade-ai-notify +--- + +## 用户需求 + +在已完成接口调用逻辑文档的基础上,进一步把"业务流程"写入 README.md,让读者能从**业务视角**理解系统:各业务域如何闭环流转、有哪些状态机与业务规则、关键动作如何触发下游变化。 + +## 内容要求 + +- 新增独立二级章节"## 业务流程",放在"配置说明"之后、"API 概览"之前(先懂业务、再懂接口的阅读顺序)。 +- 与已有"核心业务调用流程"(接口时序视角,5 条 sequenceDiagram)明确分工:新章节讲"业务上发生了什么"(状态机、业务规则、mermaid flowchart),已有章节讲"接口怎么调";两章互相引用、不重复内容。 +- 覆盖 7 个业务域:用户旅程总览、衣物生命周期、穿搭与社区、衣柜分享、交易与权益、AI 推荐、通知触发规则。 +- 所有枚举值、周期、数值必须以代码为准,不得臆测;中文撰写,风格与现有 README 一致;其他章节保持不变。 + +## 交付物 + +仅修改 /root/zt-closet-backend/README.md。 + +## 撰写方案 + +### 事实来源 + +- 业务规则事实以 internal/service/ 下 16 个 service 文件为准(用户注册额度初始化、衣物上传扣额/回退、穿搭公开审核、衣柜分享规则、订单支付/过期/退款链路、权益补偿任务、AI 推荐扣额与转正、通知触发点、运势生成调度)。 +- 枚举与常量以 internal/service/constants.go、pkg/errcode、global/setting.go 为准;已确认的枚举清单(衣物处理状态 1-5、穿搭审核 1-3、分享状态 1-4、订单状态 1-5、退款状态 0-3、额度类型 1-4、流水变更类型 1-6、商品类型 1-5、通知类型 1-3)直接复用。 +- 与既有章节一致性:插入位置锚定"## API 概览"标题前;新章节末尾与"核心业务调用流程"互链引用。 + +### 章节结构(## 业务流程 下的 7 个三级小节) + +1. **业务流程总览**:一张 flowchart 描绘端到端用户旅程(登录注册 → 上传衣物 → AI 识别入库 → 衣橱管理 → 创建穿搭 / AI 推荐 → 社区公开与互动 → 额度不足购买商品 → 权益到账)。 +2. **衣物生命周期**:上传校验(扩展名/magic bytes/大小)→ 扣上传额度 → 排队(3) → 抠图(1) → 分类(2) → 成功(4)/失败(5) 状态机 → 用户修正 15 维属性 → 删除回退额度;配状态机图 + 编号步骤。 +3. **穿搭与社区**:创建穿搭(1~10 件衣物)→ 公开(is_public=2 进入社区)→ 审核状态机(1 待审核/2 通过/3 不通过)→ 通过后可见 → 其他用户点赞/收藏 → 触发点赞通知。 +4. **衣柜分享**:部分衣物分享 / 全量快照分享 → 生成分享链接 → 访客查看分享详情 → 取消 / 到期自动过期状态机(1 正常/2 已取消/3 已过期/4 已封禁)。 +5. **交易与权益**:选购商品(5 类)→ 创建订单(15 分钟有效期)→ 微信支付回调 → 发放权益(会员/额度)→ 订单超时过期(后台每分钟扫描)→ 退款与额度回收 → 发放失败自动补偿(benefit_compensation 任务)。 +6. **AI 推荐**:扣减 AI 额度 → 大模型结合星座运势 + 衣橱衣物生成 → 落库"待用户保存"(status=3) → 用户确认保存转正式穿搭;额度不足(80000/90401)与生成失败的处理。 +7. **通知触发规则**:点赞通知(点赞时发给被赞用户)、订单通知(支付成功/退款等状态变化时)、系统通知的触发源;未读(1) → 已读(2) 流转与未读数统计。 + +### 图表规范 + +- 每节至少一张 ```mermaid 代码块(flowchart 或 stateDiagram),节点文案使用枚举原值(如"4 成功"),避免与代码不一致的表述。 +- 编号步骤紧随图表之后,每条流程 3~6 步;关键业务规则(额度扣减/回退、幂等、审核、补偿)以加粗或列表强调。 +- 篇幅控制:总览图一张紧凑流程图;各节图表节点数控制在 10 个以内;复用既有枚举表,不在新章节重复大表格。 + +### 防回归 + +- 插入位置唯一锚点:`## API 概览`;不改动其他章节内容。 +- 新增章节内标题层级为 `###`,不破坏既有目录结构;完成后用标题搜索与 mermaid 语法检查验证。 + +## 推荐扩展 + +### SubAgent + +- **code-explorer** +- 用途:读取 internal/service/ 下 16 个 service 文件与 constants.go、errcode、setting,提取业务流程撰写所需的业务规则事实(额度初始化与扣减回退时机、穿搭审核状态机、分享过期判定、订单与权益补偿链路、通知触发点、运势调度等)。 +- 预期结果:输出一份按业务域组织的规则事实清单(含具体常量数值、周期、触发条件、状态流转),作为撰写"业务流程"章节的唯一事实依据,确保不臆测。 \ No newline at end of file diff --git a/.codebuddy/plans/api-v1层完整实现_e89004bd.md b/.codebuddy/plans/api-v1层完整实现_e89004bd.md new file mode 100644 index 0000000..0795e8e --- /dev/null +++ b/.codebuddy/plans/api-v1层完整实现_e89004bd.md @@ -0,0 +1,153 @@ +--- +name: api-v1层完整实现 +overview: 实现 internal/routers/api/v1 全部 HTTP handler 并在 router.go 完成中间件与路由接线,调用已完成的 service 层;同时修复阻断运行的中间件/启动初始化问题,形成可跑通的 RESTful 接口。 +todos: + - id: fix-infra + content: 修复 middleware(requestid uuid 导入、JWTAuth 封禁判断反转与缓存回落 DB)、main.go 初始化 global.Tracer/CosClient 与运势任务、dto 列表字段补 form tag + status: completed + - id: handlers-user-basic + content: 实现 user/quota/horoscope/goods 四个 handler(登录/刷新/信息/额度/运势/商品) + status: completed + dependencies: + - fix-infra + - id: handlers-clothes-outfit + content: 实现 clothes(含 multipart 上传)与 outfit 两个 handler,覆盖增删改查与公开 + status: completed + dependencies: + - fix-infra + - id: handlers-community + content: 实现 closet/collect/like/notification 四个 handler(分享、收藏、点赞、通知) + status: completed + dependencies: + - fix-infra + - id: handlers-order-ai + content: 实现 order(含微信支付/退款回调按规范返回)与 airecommend 两个 handler + status: completed + dependencies: + - fix-infra + - id: wire-router + content: 在 router.go 接线中间件链与 /api/v1 公开/鉴权路由,添加 /ping,go build 与 go vet 全量验证 + status: completed + dependencies: + - handlers-user-basic + - handlers-clothes-outfit + - handlers-community + - handlers-order-ai +--- + +## 产品概述 + +为"衣柜/穿搭"微信小程序后端补齐 **HTTP 接口层(api/v1)**:将已完成的 service 业务逻辑通过 Gin handler 暴露为 RESTful 接口,并完成路由与中间件接线,形成可跑通的完整后端服务。 + +## 核心功能 + +- **用户与登录**:微信登录、刷新 Token、获取/修改个人信息 +- **衣物管理**:上传(含图片处理)、状态查询、详情、修改、删除、列表 +- **穿搭管理**:创建、详情、修改、删除、列表、公开设置 +- **衣柜分享**:创建、详情(访客/属主)、我的分享列表、取消 +- **社区互动**:点赞/取消、点赞状态与计数、收藏/取消、收藏列表 +- **消息通知**:列表、详情、标记已读、未读数 +- **商品与订单**:商品查询/列表、下单、订单列表、取消、退款申请、微信支付/退款回调 +- **额度与运势**:额度概览与流水、今日运势 +- **AI 推荐**:发起 AI 穿搭推荐、保存到我的搭配 + +## 接口约定 + +- 统一前缀 `/api/v1`;成功返回 `{code:0,msg,data}`,失败按业务错误码返回。 +- 登录、刷新 Token、商品查询、微信回调为公开接口;其余需登录鉴权。 +- 微信支付/退款回调按微信规范返回受理结果。 + +## 技术栈 + +- 语言/框架:Go 1.27 + Gin(沿用现有项目),复用 `pkg/app`(统一响应)、`pkg/errcode`(业务错误码)、`internal/middleware`(JWT/CORS/日志/追踪/RequestID)。 +- 依赖注入:handler 结构体持有对应 `internal/service`,构造函数注入,与 service/dao 现有风格一致。 + +## 实现思路 + +新增 `internal/routers/api/v1` 下按业务域拆分的 handler,并在 `router.go` 完成中间件与路由接线。handler 只负责:请求绑定与校验、path param 覆盖标识字段、从 context 取 userID、调用 service、统一响应转换;业务逻辑与归属校验(AuthZ)保持在 service 层,handler 不重复实现。 + +统一 handler 模式: + +1. `resp := app.NewResponse(c)` +2. 绑定:JSON 用 `ShouldBindJSON`,GET query 用 `ShouldBindQuery`,失败返回 `errcode.InvalidParams.WithDetails(err.Error())` +3. REST 路径标识(uuid/code/orderNo 等)用 `c.Param(...)` 覆盖 req 对应字段 +4. `middleware.GetUserID(c.Request.Context())` 取 userID +5. 调 service,错误 `resp.ToErrorResponse(e)`,成功 `resp.ToResponse(out)` + +### 关键技术决策 + +- **同层静态路由与参数路由共存**:Gin v1.12 支持 `/notification/unread` 与 `/notification/:uuid`、`/order/refund` 与 `/order/:orderNo/cancel` 共存,无需拆分前缀,保持 URL 语义清晰。 +- **微信回调独立处理**:回调不走 JWT,handler 读 `c.GetRawData()` 原始报文传 service(service 已做 V3 验签/GCM 解密),按微信规范返回 `{"code":"SUCCESS"}`(200)或 `{"code":"FAIL","message":...}`(非 200),避免微信重复回调。 +- **文件上传**:`/clothes/upload` 用 `c.FormFile("file")` 取 `*multipart.FileHeader` 传 service。 + +## 实现要点(防回归) + +本次需一并修复阻断 api 层编译/运行的既有问题,否则接口无法跑通: + +- **middleware/requestid.go**:修复非法导入 `import "uuid"` → `github.com/google/uuid`,调用 `uuid.New().String()`(当前该文件导致 middleware 包无法编译)。 +- **middleware/lzcwyjwt.go**: +- 修复封禁判断反转:`if userInfo.Status == 1` → 应为 `!= 1` 时拒绝(1=正常),避免正常用户被拦、禁用用户放行。 +- 缓存未命中(`redis.Nil`)回落 DB(`mysqldao.UserDao.UserGetByID`),成功回填 `redisdao.UserInfoSet`,仍失败再拒绝,避免缓存过期导致合法用户被锁死。 +- **main.go 运行期初始化**(当前缺失,直接运行会 nil panic): +- `global.Tracer = otel.Tracer(global.OtelSetting.ServiceName)`(otel 默认 no-op provider 返回合法非 nil tracer,DAO 的 `global.Tracer.Start` 依赖它)。 +- `global.CosClient, _ = cos.NewCosClient()`(衣物上传 `upload.FileUploadCos` 依赖)。 +- 启动运势兜底:`tokenhub.EnsureTodayFortunes()` 与 `tokenhub.StartFortuneScheduler()`。 +- **dto 补 form tag**(GET query 筛选可用的最小调整):`OutfitListRequest.IsPublic/Source`、`GoodsListRequest.GoodsType`、`ClosetShareListRequest.Status` 当前仅 `json` tag,需补 `form` tag 才能 `ShouldBindQuery` 绑定。 +- 不打印 token/密钥;绑定错误文本作为 details 返回可接受,不泄露内部实现。 + +## 架构与数据流 + +请求 → Gin 中间件链(Recovery → RequestID → OTel → Slog → CORS)→ [公开路由 / JWTAuth 鉴权组] → v1 handler(绑定+取 userID)→ service(业务+AuthZ)→ dao/redis/pkg → 统一响应。 + +## 目录结构 + +``` +zt-closet-backend/ +├── main.go # [MODIFY] 初始化 global.Tracer / global.CosClient;启动运势定时任务与启动兜底 +├── internal/ +│ ├── dto/ +│ │ ├── outfit.go # [MODIFY] OutfitListRequest.IsPublic/Source 补 form tag +│ │ ├── goods.go # [MODIFY] GoodsListRequest.GoodsType 补 form tag +│ │ └── closet.go # [MODIFY] ClosetShareListRequest.Status 补 form tag +│ ├── middleware/ +│ │ ├── requestid.go # [MODIFY] 修复 uuid 导入路径 +│ │ └── lzcwyjwt.go # [MODIFY] 修复封禁状态判断反转 + 缓存未命中回落 DB 回填 +│ └── routers/ +│ ├── router.go # [MODIFY] 中间件链接线 + /api/v1 公开与鉴权路由注册 + /ping +│ └── api/v1/ +│ ├── user.go # [NEW] UserHandler:WxLogin/RefreshToken/InfoGetBySelf/ProfileUpdate +│ ├── quota.go # [NEW] QuotaHandler:Info/FlowList +│ ├── horoscope.go # [NEW] HoroscopeHandler:Today +│ ├── clothes.go # [NEW] ClothesHandler:Upload/List/Get/Status/Update/Delete +│ ├── outfit.go # [NEW] OutfitHandler:Create/Get/Update/Delete/List/Public +│ ├── closet.go # [NEW] ClosetShareHandler:Create/Detail/List/Cancel +│ ├── collect.go # [NEW] CollectHandler:Collect/List +│ ├── like.go # [NEW] LikeHandler:Like/GetByTarget +│ ├── notification.go # [NEW] NotificationHandler:List/Unread/Get/Read +│ ├── goods.go # [NEW] GoodsHandler:GetByCode/List(公开) +│ ├── order.go # [NEW] OrderHandler:Create/List/Cancel/RefundApply/PayNotify/RefundNotify +│ └── airecommend.go # [NEW] AIRecommendHandler:Create/Save +``` + +## 路由清单(前缀 /api/v1) + +公开:POST /user/wxlogin、POST /user/refresh-token、GET /goods、GET /goods/:code、POST /order/pay/notify、POST /order/refund/notify +鉴权(JWTAuth): + +- GET /user/info、PUT /user/profile +- GET /quota/info、GET /quota/flow +- GET /horoscope/today +- POST /clothes/upload、GET /clothes、GET /clothes/:uuid、GET /clothes/:uuid/status、PUT /clothes/:uuid、DELETE /clothes/:uuid +- POST /outfit、GET /outfit、GET /outfit/:uuid、PUT /outfit/:uuid、DELETE /outfit/:uuid、PUT /outfit/:uuid/public +- POST /closet-share、GET /closet-share、GET /closet-share/:uuid、POST /closet-share/:uuid/cancel +- POST /collect、GET /collect +- POST /like、GET /like/:targetUUID +- GET /notification、GET /notification/unread、GET /notification/:uuid、POST /notification/:uuid/read +- POST /order、GET /order、POST /order/:orderNo/cancel、POST /order/refund +- POST /ai-recommend、POST /ai-recommend/save + +## SubAgent + +- **code-explorer** +- Purpose:实现阶段如需核对个别 service 方法签名或 dto 字段(如回调 body 读取、multipart 字段名、path param 与 dto 字段映射),快速跨文件确认,避免签名臆测。 +- Expected outcome:handler 与 service 签名、dto 绑定一次性对齐,减少编译期返工。 \ No newline at end of file diff --git a/.codebuddy/plans/classification-server-completion_ed2c7660.md b/.codebuddy/plans/classification-server-completion_ed2c7660.md new file mode 100644 index 0000000..32b099a --- /dev/null +++ b/.codebuddy/plans/classification-server-completion_ed2c7660.md @@ -0,0 +1,153 @@ +--- +name: classification-server-completion +overview: 完成衣物分类服务 Fashion-CLIP 的端到端落地:实现 pkg/classification Go 客户端与配置接线,补齐依赖,启动 8000 分类服务并用 image.png 跑通真实分类测试,完全对标 matting 的完成方式。 +todos: + - id: implement-classify-client + content: 新建 pkg/classification/classification.go,实现 Client、Classify、HealthCheck 与 ClassifyResult 结构体 + status: completed + - id: wire-classify-config-and-deps + content: 接线 ClassificationModel 配置并修复依赖(main.go、config.yaml、config.example.yaml、requirements.txt) + status: completed + - id: write-classify-test + content: 新建 classification_test.go,用 image.png 断言分类关键字段非空 + status: completed + dependencies: + - implement-classify-client + - wire-classify-config-and-deps + - id: run-classify-service-verify + content: 复用 matting venv 启动 8000 分类服务并跑 go test 完成真实验证 + status: completed + dependencies: + - implement-classify-client + - wire-classify-config-and-deps + - write-classify-test +--- + +## 用户需求 + +参照 matting_server 的完成方式,将衣物分类服务(Fashion-CLIP)也完整落地,使其端到端可运行、可测试。 + +## 核心内容 + +- 补齐分类服务的 Go 客户端封装,对接 Python 服务 `POST /classify` 与 `GET /healthz`。 +- 完成配置接线:`main.go` 读取 `ClassificationModel` 配置;`config.yaml` 端口改为 8000 并移除结构体不支持的 `retry` 字段;`config.example.yaml` 补示例段。 +- 修复 `classification_server/requirements.txt`,补上 FastAPI multipart 表单必需的 `python-multipart` 依赖。 +- 编写分类服务端到端测试,用仓库根目录 `image.png` 调用真实服务并断言关键分类字段非空。 +- 启动 8000 分类服务(模型已在本地缓存,离线加载),跑通 `go test` 完成真实验证。 + +## 技术栈 + +- Go 客户端:复用标准库 `net/http`、`mime/multipart`、`encoding/json`、`context`,对齐 `pkg/matting` 客户端模式。 +- Python 服务端:复用已有 `thirdparty/classification_server/main.py`(已实现两级加载,模型 `patrickjohncyh/fashion-clip` 已缓存于 `~/.cache/huggingface`,581MB,`local_files_only=True` 可离线加载)。 +- 运行环境:直接复用 `thirdparty/matting_server/.venv`(python3.12,已含 torch/transformers/fastapi/uvicorn/pillow/python-multipart),避免重复安装 torch。 + +## 实现方案 + +### Go 客户端设计(新建 `pkg/classification/classification.go`) + +- 对齐 `pkg/matting/matting.go` 的轻量客户端模式,不引入第三方 HTTP 库。 +- `Client` 持有 `baseURL` 与 `httpClient`(Timeout 建议 120s,CLIP 单次前向 210 个提示词在 CPU 上耗时较长)。 +- `NewClient()` 从 `global.ClassificationModelSetting.Host/Port` 拼接地址;`NewClientWithAddr(host string, port int)` 便于测试解耦。 +- `Classify(ctx, imageData []byte) (*ClassifyResult, error)`:构造 multipart/form-data(字段名 `file`,与 Python 端 `UploadFile` 对齐),POST 到 `/classify`,将 JSON 响应解析为 `ClassifyResult`;非 200 时返回状态码与响应体摘要(复用截断逻辑,避免打印大 JSON)。 +- `HealthCheck(ctx) error`:GET `/healthz`,供测试前置就绪检查。 + +### 配置接线与依赖修复 + +- `main.go` 的 `setupSetting()` 仿照 `MattingModel` 增加 `setting.ReadSection("ClassificationModel", &global.ClassificationModelSetting)`,避免配置为 nil 导致 `NewClient` panic。 +- `configs/config.yaml`:`ClassificationModel.Port` 由 80 改为 8000;移除 `retry: 3`(`ClassificationModelSettingS` 无此字段)。 +- `configs/config.example.yaml`:补充 `ClassificationModel`(Host 127.0.0.1 / Port 8000)示例段。 +- `thirdparty/classification_server/requirements.txt`:补 `python-multipart>=0.0.9`,并保留与 matting 一致的注释说明。 + +### 测试设计(新建 `pkg/classification/classification_test.go`) + +- 读取 `../../image.png`(测试文件位于 `pkg/classification/` 下)。 +- `NewClientWithAddr("127.0.0.1", 8000)`,先 `HealthCheck` 确认服务就绪(失败则 `t.Skipf` 明确提示),再调用 `Classify`。 +- 断言:返回结果非空、`CategoryMain` 非空、`Formality` 与 `VisualImpact` 落在 `1`~`5`;可选记录 `CategorySub`、`ColorMain` 等关键字段便于人工核验。 +- context 超时设 180s,覆盖 CPU 推理耗时。 + +### 服务启动与验证 + +- 复用 matting venv 启动服务:`thirdparty/matting_server/.venv/bin/python thirdparty/classification_server/main.py`(监听 8000),日志重定向到 `/tmp/classification_server.log`。 +- 轮询 `GET /healthz` 直到返回 `{"status":"ok"}`(模型离线加载完成后才就绪)。 +- 执行 `go test ./pkg/classification/ -run TestClassify -v -timeout 600s` 完成真实分类验证。 + +## 实现注意事项 + +- **性能**:CLIP 单次批量前向(约 210 文本 + 1 图)在 CPU 上约几十秒,客户端 Timeout 与测试 context 需相应放宽;模型已在本地缓存,启动零网络请求。 +- **日志**:Python 端已有「命中本地缓存/自动下载」日志;Go 客户端错误信息需截断响应体(如 512 字节),避免打印大 JSON 造成日志噪声。 +- **兼容性**:fashion-clip 为标准 `CLIPModel`,非 `trust_remote_code` 自定义代码,不会触发 BiRefNet 在 transformers 5.x 下的兼容问题;CPU 加载保持 fp32,无需额外 dtype 转换。 +- **blast radius**:仅新增客户端/测试文件,配置改动最小化;不引入新第三方依赖,不触碰现有 matting 与业务逻辑。 + +## 目录结构 + +``` +thirdparty/classification_server/ +├── requirements.txt # [MODIFY] 补 python-multipart 依赖 +pkg/classification/ +├── classification.go # [NEW] 分类服务 Go 客户端(Client/NewClient/NewClientWithAddr/Classify/HealthCheck/ClassifyResult) +└── classification_test.go # [NEW] 端到端测试,用 image.png 验证真实分类 +main.go # [MODIFY] setupSetting 增加 ClassificationModel 配置读取 +configs/config.yaml # [MODIFY] ClassificationModel.Port 改 8000,移除 retry 字段 +configs/config.example.yaml # [MODIFY] 补 ClassificationModel 示例段 +``` + +## 关键代码结构 + +``` +type DetailItem struct { + Label string `json:"label"` + Confidence float64 `json:"confidence"` +} + +type Details struct { + CategoryMain []DetailItem `json:"category_main"` + CategorySub []DetailItem `json:"category_sub"` + Style []DetailItem `json:"style"` + ColorMain []DetailItem `json:"color_main"` + Thickness []DetailItem `json:"thickness"` + Version []DetailItem `json:"version"` + Material []DetailItem `json:"material"` + Pattern []DetailItem `json:"pattern"` + SleeveType []DetailItem `json:"sleeve_type"` + ClothesType []DetailItem `json:"clothes_type"` + NecklineType []DetailItem `json:"neckline_type"` + Formality []DetailItem `json:"formality"` + Occasions []DetailItem `json:"occasions"` + VisualImpact []DetailItem `json:"visual_impact"` +} + +type ClassifyResult struct { + CategoryMain string `json:"category_main"` + CategoryMainConfidence float64 `json:"category_main_confidence"` + CategorySub string `json:"category_sub"` + CategorySubConfidence float64 `json:"category_sub_confidence"` + Style string `json:"style"` + StyleConfidence float64 `json:"style_confidence"` + ColorMain string `json:"color_main"` + ColorMainConfidence float64 `json:"color_main_confidence"` + ColorSub string `json:"color_sub"` + ColorSubConfidence float64 `json:"color_sub_confidence"` + Thickness string `json:"thickness"` + ThicknessConfidence float64 `json:"thickness_confidence"` + Version string `json:"version"` + VersionConfidence float64 `json:"version_confidence"` + Material string `json:"material"` + MaterialConfidence float64 `json:"material_confidence"` + Pattern string `json:"pattern"` + PatternConfidence float64 `json:"pattern_confidence"` + SleeveType string `json:"sleeve_type"` + SleeveTypeConfidence float64 `json:"sleeve_type_confidence"` + ClothesType string `json:"clothes_type"` + ClothesTypeConfidence float64 `json:"clothes_type_confidence"` + NecklineType string `json:"neckline_type"` + NecklineTypeConfidence float64 `json:"neckline_type_confidence"` + Formality string `json:"formality"` + FormalityLabel string `json:"formality_label"` + FormalityConfidence float64 `json:"formality_confidence"` + Occasions string `json:"occasions"` + VisualImpact string `json:"visual_impact"` + VisualImpactLabel string `json:"visual_impact_label"` + VisualImpactConfidence float64 `json:"visual_impact_confidence"` + Details Details `json:"details"` +} +``` \ No newline at end of file diff --git a/.codebuddy/plans/classification_server_python_run_77f6663b.md b/.codebuddy/plans/classification_server_python_run_77f6663b.md new file mode 100644 index 0000000..a2bff4e --- /dev/null +++ b/.codebuddy/plans/classification_server_python_run_77f6663b.md @@ -0,0 +1,58 @@ +--- +name: classification_server_python_run +overview: 为纯 Python 分类服务补齐独立运行配套:新增 requirements.txt 依赖清单,并在 main.py 末尾添加 uvicorn 启动入口,使 `python main.py` 一条命令即可启动,完全不依赖 Go。 +todos: + - id: create-requirements + content: 新建 thirdparty/classification_server/requirements.txt 依赖清单并添加 GPU 安装说明注释 + status: completed + - id: add-start-entry + content: 在 main.py 末尾追加 uvicorn 启动入口并做语法验证 + status: completed +--- + +## 用户需求 + +分类服务(Fashion-CLIP 衣物分类)要能完全不依赖 Go 项目、纯 Python 独立运行。用户已确认需要补齐以下运行配套文件: + +- 新增 `requirements.txt` 依赖清单 +- 在 `main.py` 末尾添加启动入口,使 `python main.py` 一条命令即可启动服务 + +不要求本地测试脚本和 README。 + +## 核心功能 + +- **依赖清单**:声明 torch、transformers、fastapi、uvicorn、pillow 五个依赖及版本下限,并注明 GPU 用户需单独安装 CUDA 版 torch +- **一键启动**:`python main.py` 直接以 0.0.0.0:8000 启动 FastAPI 服务,无需 `uvicorn main:app` 命令 + +## 技术方案 + +### 实现方式 + +1. 新建 `thirdparty/classification_server/requirements.txt`,列出运行所需的 5 个第三方依赖,采用宽松下限版本避免锁死小版本: + +- torch>=2.0.0、transformers>=4.30.0、fastapi>=0.100.0、uvicorn>=0.23.0、pillow>=9.5.0 +- 文件内添加注释:PyPI 默认安装 CPU 版 torch,GPU 用户需按 PyTorch 官网命令单独安装对应 CUDA 版本 + +2. 修改 `main.py` 末尾(第 594-596 行 `/healthz` 路由之后)追加启动入口: + +```python +if **name** == "**main**": +import uvicorn +uvicorn.run(app, host="0.0.0.0", port=8000) +``` + +- **关键点**:Fashion-CLIP 模型在模块 import 时于模块级加载(`model`、`processor` 为模块级变量),因此必须直接传 `app` 对象调用 `uvicorn.run(app, ...)`,不能使用 `"main:app"` 字符串加 reload 的方式,否则会导致模型重复加载 +- uvicorn 放在入口内局部 import,保持顶部 import 区原结构不变,改动最小化 + +### 验证方式 + +- `python3 -m py_compile main.py` 确认语法无误 +- 交付后运行验证:`pip install -r requirements.txt && python main.py`,访问 `http://localhost:8000/healthz` 返回 `{"status": "ok"}` + +### 目录结构 + +``` +thirdparty/classification_server/ +├── main.py # [MODIFY] 末尾追加 __main__ 启动入口,python main.py 直接启动 +└── requirements.txt # [NEW] 依赖清单(torch/transformers/fastapi/uvicorn/pillow + GPU 安装说明注释) +``` \ No newline at end of file diff --git a/.codebuddy/plans/classify-service-align-clothes-model_85d8b823.md b/.codebuddy/plans/classify-service-align-clothes-model_85d8b823.md new file mode 100644 index 0000000..f598c34 --- /dev/null +++ b/.codebuddy/plans/classify-service-align-clothes-model_85d8b823.md @@ -0,0 +1,176 @@ +--- +name: classify-service-align-clothes-model +overview: 根据 mysqlmodel.Clothes 的 15 个分类维度字段,全面重构 thirdparty/classification_server/main.py 分类服务:所有维度(含已有)的 prompt 大幅扩充至全面覆盖各种情况,补齐 8 个缺失分类维度,响应字段命名与 model 对齐,并将多次模型前向推理合并为单次批量前向以提升性能。 +todos: + - id: expand-existing-prompts + content: 扩充已有 6 组 prompt:颜色至 30、子类别至约 60、风格至 25、版型加直筒、材质至 17、图案至 19,全部配详细英文视觉描述 + status: completed + - id: add-new-dimension-prompts + content: 新增 7 组维度 prompt(厚度/袖型/衣长/领型/正式度/场合/视觉冲击)、维度适用性映射表与副颜色阈值常量 + status: completed + dependencies: + - expand-existing-prompts + - id: implement-batch-forward + content: 实现 _classify_all 单次批量前向:按组 softmax、子类别按大类过滤、副颜色 top-2 阈值提取 + status: completed + dependencies: + - add-new-dimension-prompts + - id: rewrite-classify-api + content: 重写 /classify 接口:响应字段对齐 Clothes 模型命名、不适用维度置空、occasions top-N 逗号连接、formality 与 visual_impact 返回数字加标签 + status: completed + dependencies: + - implement-batch-forward + - id: preprocess-verify + content: 优化图片预处理(仅透明图合成白底)并运行 Python 语法检查与启动自检验证 + status: completed + dependencies: + - rewrite-classify-api +--- + +## 产品概述 + +根据 `mysqlmodel.Clothes` 的 15 个分类维度字段,全面优化 Fashion-CLIP 衣物分类服务(`thirdparty/classification_server/main.py`):不仅补齐缺失维度,所有已有维度的 prompt 也要大幅扩充细化,覆盖各种衣物边界情况(多色拼色、稀有颜色、特殊袖型领型、加厚冬装、各类场合等),同时将推理从逐组多次前向合并为单次批量前向,响应字段与模型命名完全对齐。 + +## 核心功能 + +- **已有 prompt 全面扩充**:颜色扩至 30+(新增玫红/荧光/墨绿/宝蓝/驼色/砖红等),子类别扩至约 60 个(补针织衫/工装裤/喇叭裤/玛丽珍鞋/马丁靴/发带/戒指/胸针/手表等),风格扩至 25 个(补极简/波西米亚/朋克/嘻哈/森系/法式/日系/韩系等),材质扩至 17 个(补天鹅绒/缎面/网纱/羊羔绒/PU皮/磨毛棉),图案扩至 19 个(补千鸟格/菱格纹/人字纹/拼色块/渐变/豹纹/几何/logo等) +- **补齐 8 个缺失维度**:副颜色(color_sub,颜色 top-2 阈值提取)、厚度(4 档)、袖型(8 档)、衣长类型(5 档)、领型(10 档)、正式程度(1-5)、适用场合(10 档 top-N)、视觉冲击力(1-5) +- **单次批量前向**:约 210 个文本合并为一次模型前向,按组 softmax,推理耗时从 15 次降为 1 次 +- **品类适用性控制**:鞋/配饰等品类的不适用维度自动置空,避免不合理标签 +- **响应对齐模型**:category_main/category_sub/color_main/color_sub/thickness/version/material/pattern/sleeve_type/clothes_type/neckline_type/formality/occasions/visual_impact,均附 *_confidence,details 保留 Top-5 明细 + +## 边界说明 + +- 改动仅限 `thirdparty/classification_server/main.py` 单文件,Go 侧无消费方,字段改名无破坏风险 +- 多色衣物通过 color_sub 副颜色机制表达,颜色组内不放"多色"标签避免抢占主色 +- 所有 prompt 沿用"中文标签键 + 详细英文视觉描述值"模式,英文描述包含剪裁、长度、材质视觉特征 + +## 技术栈 + +- 沿用现有:Python 3 + FastAPI + Transformers(patrickjohncyh/fashion-clip)+ PyTorch,不引入新依赖 + +## 实现方案 + +### 核心策略 + +1. **Prompt 维度全覆盖**:已有 6 组 prompt 扩充 + 新增 7 组维度 prompt,全部收敛为统一 dict 结构 +2. **单次批量前向**:新增 `_classify_all`,全部组(约 210 个文本)拼一个 batch 一次推理,输出 logits 按组 start/end 边界切分后分别 softmax +3. **子类别等价合并**:所有大类子类别合并进同一批前向,大类确定后在其组内取 top1——同组内相对分数不变,argmax 与当前两阶段方案数学等价 +4. **适用性映射**:`DIMENSION_APPLICABILITY` 表声明各维度适用的大类,不适用直接返回空字符串 +5. **副颜色逻辑**:颜色组 top-2,第二置信度达到阈值(top1 的 40% 与绝对值 0.15 取较低者)才输出 color_sub + +```mermaid +flowchart LR + A[图片上传] --> B[预处理: 仅透明图合成白底] + B --> C[一次前向: 全部维度约210文本拼batch] + C --> D[按组softmax归一化] + D --> E[大类 top1] + E --> F[子类别: 大类组内 top1] + E --> G[颜色 top1/top2 主色/副色] + E --> H[其余维度 top1 或 top-N] + H --> I[适用性映射: 不适用置空] + F --> J[响应字段对齐 model] + G --> J + I --> J +``` + +### Prompt 扩充清单(各组完整标签与描述规范) + +**CATEGORY_PROMPTS(6 类,保持)**:上装/下装/连衣裙/外套/鞋/配饰。描述要点:明确穿着部位与廓形边界(如连衣裙强调"从肩到腿一件式",配饰强调"非服装的小件")。 + +**SUBCATEGORY_GROUPS(约 60 个)** + +- 上装(10):T恤/衬衫/卫衣/毛衣/针织衫/背心/Polo衫/开衫/吊带衫/打底衫 +- 下装(10):牛仔裤/休闲裤/运动裤/短裤/半身裙/阔腿裤/西装裤/鲨鱼裤/工装裤/喇叭裤 +- 连衣裙(7):连衣短裙/连衣长裙/吊带裙/衬衫连衣裙/针织连衣裙/雪纺连衣裙/西装连衣裙 +- 外套(9):夹克/西装外套/风衣/羽绒服/大衣/马甲/牛仔外套/皮衣/棉服 +- 鞋(11):运动鞋/皮鞋/凉鞋/靴子/帆布鞋/高跟鞋/乐福鞋/外穿拖鞋/玛丽珍鞋/洞洞鞋/马丁靴 +- 配饰(13):帽子/围巾/腰带/墨镜/项链/手链/包包/耳饰/丝巾/发带/戒指/胸针/手表 + +描述规范:每条必须包含款式特征词(领型/口袋/闭合方式/鞋面结构等)+ 材质质感词 + 穿着场景词,如针织衫需区分"ribbed knit texture, pullover without buttons"与卫衣的"fleece fabric, hood, kangaroo pocket"。 + +**COLOR_PROMPTS(30 个)** +原有 20:黑/白/红/绿/黄/粉/紫/灰/棕/橙/卡其/米/杏/焦糖/藏青/天蓝/军绿/酒红/金/银 +新增 10:玫红/荧光色/墨绿/宝蓝/浅蓝/粉紫/驼色/砖红/藏蓝/浅灰 + +描述规范:新增颜色需给出色相 + 明度 + 饱和度描述(如宝蓝"bright vivid royal blue, saturated deep bright blue",浅蓝"light pale blue, soft pastel sky tone");金色/银色强调 metallic 金属光泽;荧光色强调 neon 高饱和度。 + +**STYLE_PROMPTS(25 个)** +原有 16:休闲/通勤/运动/街头/优雅/甜美/复古/简约/商务正装/轻熟/工装/学院/甜酷/辣妹/度假/新中式 +新增 9:极简风/波西米亚风/朋克风/嘻哈风/森系风/法式风/美式复古风/日系风/韩系风 + +描述规范:每条包含整体气质 + 典型元素(如波西米亚"flowy fabric, fringe, ethnic embroidery, relaxed silhouette";朋克"black leather, studs, ripped details, rebellious look")。 + +**FIT_PROMPTS(6 个)**:宽松/修身/阔腿/紧身/常规合身/直筒。描述强调廓形与身体关系(hugs body / straight parallel legs / oversized)。 + +**MATERIAL_PROMPTS(17 个)** +原有 11:牛仔布/针织/皮质/纯棉/羊毛/丝绸/雪纺/麻料/蕾丝/尼龙/灯芯绒 +新增 6:天鹅绒/缎面/网纱/羊羔绒/PU皮/磨毛棉 + +描述规范:强调表面质感特征(如天鹅绒"soft plush velvet surface, rich deep sheen";缎面"smooth glossy satin sheen, silky shine";网纱"sheer mesh tulle";羊羔绒"fluffy fleece";磨毛棉"brushed soft cotton, slightly fuzzy surface"),区分真皮与 PU 皮的光泽差异。 + +**PATTERN_PROMPTS(19 个)** +原有 10:纯色/条纹/格纹/印花/刺绣/波点/迷彩/扎染/碎花/字母 +新增 9:千鸟格/菱格纹/人字纹/拼色块/渐变/豹纹/蕾丝花纹/几何图案/logo图案 + +描述规范:强调图案构成方式(如千鸟格"houndstooth check, small broken zigzag checks";菱格纹"argyle diamond pattern";人字纹"herringbone zigzag weave";拼色块"color blocking, contrasting solid color panels")。 + +**新增 THICKNESS_PROMPTS(4 档)**:薄(sheer lightweight summer)/适中(regular medium weight)/厚(thick warm winter)/加厚(heavily padded down, quilted thick insulation)。适用:上装/下装/连衣裙/外套/鞋。 + +**新增 SLEEVE_TYPE_PROMPTS(8 档)**:无袖/吊带/短袖/中袖/长袖/喇叭袖/泡泡袖/落肩袖。描述强调袖长比例与袖型结构(喇叭袖"flared bell sleeve widening at cuff",泡泡袖"puffed voluminous sleeve with gathered shoulder",落肩袖"dropped shoulder seam, relaxed slouchy sleeve")。适用:上装/连衣裙/外套。 + +**新增 CLOTHES_TYPE_PROMPTS(5 档)**:短款/常规/中长/长款/超长款。描述以身体参照点界定衣长(短款"ends above waist, cropped";中长"mid-thigh to knee";超长"ankle-length or floor-length, maxi")。适用:上装/下装/连衣裙/外套。 + +**新增 NECKLINE_PROMPTS(10 档)**:圆领/V领/方领/高领/翻领/立领/一字领/挂脖/连帽/娃娃领。描述强调领口形状与领部结构(方领"square shaped neckline with straight edges",立领"standing band collar, mandarin collar",挂脖"halter neck with straps behind neck",娃娃领"peter pan collar, flat rounded collar")。适用:上装/连衣裙/外套。 + +**新增 FORMALITY_PROMPTS(5 档)**:标签(非常休闲/休闲/轻正式/商务正式/非常正式)对应等级 1-5,返回 `formality="1"~"5"` 字符串 + `formality_label` 中文标签。描述用场景锚定(如 5"evening gown, tuxedo, ceremonial formal wear";1"sporty gym wear, beach casual")。 + +**新增 OCCASION_PROMPTS(10 档)**:日常通勤/运动健身/约会聚会/正式商务/居家休闲/度假旅游/户外活动/派对夜店/校园上课/婚礼宴会。取置信度达标的 top-N(如置信度不低于 top1 的 60%)用逗号连接返回 occasions 字符串。 + +**新增 VISUAL_IMPACT_PROMPTS(5 档)**:标签(低调/平淡/适中/亮眼/高调)对应等级 1-5,返回 `visual_impact="1"~"5"` + `visual_impact_label`。描述锚定视觉特征(如 5"bold neon, glitter, dramatic design";1"plain neutral minimal")。 + +### 响应结构(对齐 model) + +``` +{ + "category_main": "上装", + "category_sub": "针织衫", + "style": "韩系风", + "color_main": "宝蓝", + "color_sub": "白色", + "thickness": "适中", + "version": "修身", + "material": "针织", + "pattern": "条纹", + "sleeve_type": "长袖", + "clothes_type": "常规", + "neckline_type": "圆领", + "formality": "3", + "formality_label": "轻正式", + "occasions": "日常通勤,约会聚会", + "visual_impact": "3", + "visual_impact_label": "适中", + "details": { "...各组 Top-5 明细,按新字段命名,含置信度..." } +} +``` + +### 实现要点 + +- **性能**:前向 7 次 → 1 次(约 210 文本,CLIP 单 batch 可承受,CPU 场景收益最大);避免每组重复 tokenize 开销 +- **等价性**:按组 softmax 与逐组调用结果一致;子类别"组内 top1"与两阶段 argmax 完全一致 +- **健壮性**:`_classify_all` 用 start/end 边界切片防顺序错乱;大类不在适用性表中时安全回退(维度置空、子类别为空) +- **可维护性**:新增维度只需加 dict 项 + 注册分组,无需改前向逻辑;/healthz 保留;docstring 同步更新 +- **预处理**:仅当图片含 alpha 通道(PNG)才合成白底,JPEG 直接转 RGB + +## 目录结构 + +``` +thirdparty/ +└── classification_server/ + └── main.py # [MODIFY] 唯一改动文件 + # 1. 扩充 6 组已有 prompt(颜色30/子类别60/风格25/版型6/材质17/图案19) + # 2. 新增 7 组维度 prompt + DIMENSION_APPLICABILITY + 副色阈值常量 + # 3. 新增 _classify_all 单次批量前向(替代逐组 _classify_group 循环) + # 4. 重写 /classify 接口:批量前向、副色提取、适用性置空、响应对齐 model + # 5. 优化图片预处理:检测 alpha 后才合成白底 +``` \ No newline at end of file diff --git a/.codebuddy/plans/classify-service-align-clothes-model_e429d660(未完成).md b/.codebuddy/plans/classify-service-align-clothes-model_e429d660(未完成).md new file mode 100644 index 0000000..c955f17 --- /dev/null +++ b/.codebuddy/plans/classify-service-align-clothes-model_e429d660(未完成).md @@ -0,0 +1,127 @@ +--- +name: classify-service-align-clothes-model +overview: 根据 mysqlmodel.Clothes 的 15 个分类维度字段,优化 thirdparty/classification_server/main.py 分类服务:补齐缺失的分类维度(副颜色/厚度/袖型/衣长/领型/正式程度/适用场合/视觉冲击力)、响应字段命名与 model 对齐,并将多次模型前向推理合并为单次批量前向以提升性能。 +todos: + - id: add-prompt-groups + content: 在 main.py 新增 7 组缺失维度 prompt 定义、副颜色阈值常量与维度适用性映射表 + status: pending + - id: batch-forward + content: 实现 _classify_all 单次批量前向推理,按组 softmax 并完成子类别按大类过滤与副颜色 top-2 提取 + status: pending + dependencies: + - add-prompt-groups + - id: rewrite-classify-api + content: 重写 /classify 接口响应,字段对齐 Clothes 模型命名并实现不适用维度置空逻辑 + status: pending + dependencies: + - batch-forward + - id: preprocess-and-verify + content: 优化图片预处理(仅透明图合成白底)并运行 python 语法与启动自检验证 + status: pending + dependencies: + - rewrite-classify-api +--- + +## 产品概述 + +根据 `mysqlmodel.Clothes` 的 15 个分类维度字段,优化 Fashion-CLIP 衣物分类服务(`thirdparty/classification_server/main.py`),使分类输出与模型字段完全对齐、维度补齐、推理性能提升。 + +## 核心功能 + +- **字段命名对齐**:响应字段由 type/subcategory/color/fit 改为 category_main/category_sub/color_main/version 等 snake_case 命名,与模型及 DTO 的 json tag 一致,便于 Go 侧直接映射落库 +- **补齐 8 个缺失维度**:新增副颜色(color_sub,从颜色 top-2 提取)、厚度(thickness)、袖型(sleeve_type)、衣长类型(clothes_type)、领型(neckline_type)、正式程度(formality,1-5)、适用场合(occasions,多个场合逗号分隔)、视觉冲击力(visual_impact,1-5) +- **单次批量前向推理**:将当前 7 次逐组模型前向合并为 1 次批量前向,按组 softmax 归一化,推理耗时大幅下降 +- **品类适用性控制**:按大类判断维度适用性(如鞋/配饰无袖型、领型,置空不输出),避免给出不合理标签 +- **图片预处理优化**:仅对含透明通道的图片合成白底,普通 JPEG 直接转 RGB,减少冗余计算 + +## 边界说明 + +- 改动仅限 `thirdparty/classification_server/main.py` 单文件 +- Go 侧目前无消费方,字段改名无兼容性风险 +- Formality/VisualImpact 返回 "1"~"5" 字符串(模型字段为 string 类型) +- 子类别过滤逻辑保持"避免牛仔裤出现在上装里"的原有语义 + +## 技术栈 + +- 沿用现有:Python 3 + FastAPI + Transformers(Fashion-CLIP)+ PyTorch +- 不改动模型、不引入新依赖 + +## 实现方案 + +### 核心策略 + +1. **维度补齐**:为 8 个缺失维度新增英文 prompt 组(沿用现有中文标签键 + 英文视觉描述值模式) +2. **单次批量前向**:新增 `_classify_all`,将所有 prompt 组(约 150 个文本)拼成一个 batch 一次推理,输出 logits 后按组切分分别 softmax 归一化 +3. **子类别等价合并**:所有大类的子类别合并进同一次前向,大类确定后在其子类别组内取 top1——同组内相对分数不变,argmax 结果与当前两阶段方案数学等价,省掉第二次前向 +4. **适用性映射**:用 `DIMENSION_APPLICABILITY` 表声明各维度适用的大类,不适用维度直接返回空字符串 +5. **副颜色逻辑**:从颜色组 top-2 中取副色,第二置信度需达到阈值(建议为 top1 的 40% 或绝对值 0.15,取较低者更宽松)才输出,否则置空 + +```mermaid +flowchart LR + A[图片上传] --> B[预处理: 仅透明图合成白底] + B --> C[一次前向: 全部维度prompt拼batch] + C --> D[按组softmax归一化] + D --> E[大类 top1] + E --> F[子类别: 大类组内 top1] + E --> G[颜色 top1/top2 → 主色/副色] + E --> H[其余维度 top1 或 top-N] + H --> I[适用性映射: 不适用的置空] + F --> J[响应字段对齐 model 命名] + G --> J + I --> J +``` + +### 新增 Prompt 组设计 + +- **THICKNESS_PROMPTS**:厚/适中/薄 +- **SLEEVE_TYPE_PROMPTS**:无袖/短袖/中袖/长袖(仅上装、连衣裙、外套适用) +- **CLOTHES_TYPE_PROMPTS**:短款/常规/中长/长款(上装、下装、连衣裙、外套适用) +- **NECKLINE_PROMPTS**:圆领/V领/方领/高领/翻领/一字领/连帽(仅上装、连衣裙、外套适用) +- **FORMALITY_PROMPTS**:5 档英文描述,返回 "1"~"5" 字符串 + 中文标签(如 休闲/日常/轻正式/商务/正式) +- **OCCASION_PROMPTS**:日常通勤/运动健身/约会聚会/正式商务/居家休闲/度假旅游/户外活动,取置信度达标的 top-N 用逗号连接 +- **VISUAL_IMPACT_PROMPTS**:5 档英文描述,返回 "1"~"5" 字符串 + 中文标签(低调/平淡/适中/亮眼/高调) + +### 响应结构(对齐 model) + +``` +{ + "category_main": "上装", + "category_sub": "T恤", + "style": "休闲风", + "color_main": "黑色", + "color_sub": "白色", + "thickness": "适中", + "version": "宽松", + "material": "纯棉", + "pattern": "纯色", + "sleeve_type": "短袖", + "clothes_type": "常规", + "neckline_type": "圆领", + "formality": "2", + "occasions": "日常通勤,居家休闲", + "visual_impact": "3", + "details": { "…各维度 Top-5 明细,按新字段命名…" } +} +``` + +各维度附带 `*_confidence` 字段;details 保留 Top-5 明细便于调试与后续微调标签。 + +## 实现要点 + +- **性能**:前向次数 7 次 → 1 次(文本数约 150,CLIP 单 batch 可承受;CPU 场景收益最大);避免每组一次 processor 调用的重复 tokenize 开销 +- **等价性保证**:批量方案下每组的 softmax 与逐组调用结果一致(组内归一化),子类别"组内 top1"与大类过滤后两阶段 argmax 完全一致 +- **健壮性**:`_classify_all` 内部用 start/end 边界记录各组切片位置,防止顺序错乱;大类不在映射表中时安全回退(维度置空、子类别为空) +- **兼容性**:保留 `/healthz` 与整体接口形态;docstring 同步更新字段说明 +- **可维护性**:prompt 组全部收敛为统一 dict 结构,新增维度只需加 dict 项 + 注册到批量分组,无需新增前向逻辑 + +## 目录结构 + +``` +thirdparty/ +└── classification_server/ + └── main.py # [MODIFY] 唯一改动文件 + # 1. 新增 7 组 prompt 定义 + 副颜色阈值常量 + 维度适用性映射表 + # 2. 新增 _classify_all 批量单次前向函数(替代逐组 _classify_group 循环) + # 3. 重写 /classify 接口:调用批量前向、副颜色提取、适用性置空、响应字段对齐 model + # 4. 优化图片预处理:检测 alpha 通道后才合成白底 +``` \ No newline at end of file diff --git a/.codebuddy/plans/dao-tracing-refactor_d4fb1e5c.md b/.codebuddy/plans/dao-tracing-refactor_d4fb1e5c.md new file mode 100644 index 0000000..6166460 --- /dev/null +++ b/.codebuddy/plans/dao-tracing-refactor_d4fb1e5c.md @@ -0,0 +1,133 @@ +--- +name: dao-tracing-refactor +overview: 按 UserCreate 的链路追踪模式,为 mysqldao(13 文件) 与 redisdao(10 文件) 中所有 DAO 方法/函数统一补全 tracing span(Start/defer End/RecordError),并保持增删改查list 顺序。 +todos: + - id: mysqldao-batch-a + content: 按样板改造 mysqldao 的 user.go 剩余方法、auditlog.go、closet.go、clothes.go、collect.go、goods.go,补充 Span 追踪与错误记录 + status: completed + - id: mysqldao-batch-b + content: 改造 mysqldao 的 horoscope.go、like.go、notification.go、order.go、outfit.go、quota.go、vipflowlog.go,处理 RowsAffected 分支并加追踪 + status: in_progress + dependencies: + - mysqldao-batch-a + - id: redisdao-batch-a + content: 改造 redisdao 的 auth.go、closet.go、collect.go、horoscope.go、like.go,处理管道 Exec 与 redis.Nil 豁免并加追踪 + status: pending + - id: redisdao-batch-b + content: 改造 redisdao 的 lock.go、notification.go、outfit.go、quota.go、user.go,处理 Lua 脚本与 redis.Nil 豁免(user.go 补充 go-redis 导入) + status: pending + dependencies: + - redisdao-batch-a + - id: verify-build + content: 执行 go build ./... 与 go vet ./... 验证编译通过、无新增告警且语义不变 + status: pending + dependencies: + - mysqldao-batch-b + - redisdao-batch-b +--- + +## 产品概述 + +为 `zt-closet-backend` 的数据访问层(DAO)统一接入 OpenTelemetry 链路追踪能力,使 MySQL 与 Redis 的每个数据操作方法都具备可观测的 Span 追踪和错误记录,便于分布式追踪与线上问题定位。 + +## 核心功能 + +- 以 `UserCreate` 方法为改造样板,为 `internal/mysqldao` 下全部 DAO 方法(13 个文件)补充统一的 Span 追踪与错误记录逻辑。 +- 为 `internal/redisdao` 下全部包级函数/方法(10 个文件)补充相同风格的 Span 追踪与错误记录逻辑。 +- 统一改造模式:方法开头启动 Span(命名 `dao.<方法名>`)→ `defer span.End()` → 执行原有 DB/缓存调用 → 出错时先 `span.RecordError(err)` 再返回。 +- 保持各方法原有业务逻辑、入参与返回值语义完全不变,仅新增追踪与错误记录。 +- 对 Redis 查询"未命中"(`redis.Nil`)等属于正常业务分支的场景不记录为错误,避免污染追踪链路。 +- 维持既有 "增-删-改-查-list" 的方法排列顺序。 + +## 技术栈 + +- 语言:Go +- ORM:GORM(`gorm.io/gorm`),已通过 `d.db.WithContext(ctx)` 传递上下文 +- 缓存:go-redis v9(`github.com/redis/go-redis/v9`) +- 追踪:OpenTelemetry,全局追踪器 `global.Tracer`(定义于 `global/otel.go` 的 `var Tracer trace.Tracer`) + +## 实现方式 + +基于用户提供的 `UserCreate` 样板,对每个 DAO 方法套用统一的追踪装饰模式。这是纯增量式改造,不改变任何业务语义与函数签名,风险可控、易于批量执行。 + +### 改造模板(mysqldao,结构体方法) + +``` +func (d *XxxDao) XxxCreate(ctx context.Context, ...) (...) { + ctx, span := global.Tracer.Start(ctx, "dao.XxxCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(x).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} +``` + +- 单行 `return ...Error` 需改写为 `if err := ...; err != nil { span.RecordError(err); return err }` 形式。 +- 带 `RowsAffected` 返回值的方法(如 `QuotaIncreaseUsed`、`OrderUpdateStatusByOrderNo`):先判断 `res.Error`,记录后再返回,保留 `RowsAffected > 0` 判断逻辑。 +- 查询类方法(`First`/`Find`)出错统一 `span.RecordError(err)` 后返回。 +- `ctx` 必须重新赋值(`ctx, span := ...`),确保新 Span 的上下文继续向下游 `WithContext(ctx)` 传递。 + +### 改造模板(redisdao,多为包级函数) + +``` +func XxxSet(ctx context.Context, ...) error { + ctx, span := global.Tracer.Start(ctx, "dao.XxxSet") + defer span.End() + + if err := global.RedisEngine.Set(ctx, key, val, ttl).Err(); err != nil { + span.RecordError(err) + return err + } + return nil +} +``` + +- redisdao 方法原多为 `return xxx.Err()` / `return xxx.Result()`,需先捕获 error/result 再记录并返回。 +- 事务管道(`TxPipeline` + `Exec`,见 `auth.go`):对 `pipe.Exec(ctx)` 返回的 err 做记录后返回。 +- Lua 脚本调用(`lock.go`、`quota.go`):对 `script.Run(...)` 返回结果的 error 做记录后返回。 +- **`redis.Nil` 处理策略**:对 Get 类"未命中属正常"的方法(如 `UserInfoGet`、`AuthUserIDByAccess`、`QuotaRemainGet`),仅当 `err != nil && err != redis.Nil` 时才调用 `span.RecordError(err)`,其余错误正常记录;`redis.Nil` 仍原样返回给调用方,语义不变。`quotaDailyValue` 已内部吞掉 `redis.Nil`,无需特殊处理。 + +### 实现要点 + +- **保持顺序**:延续既有 "增删改查list" 排列,不调整方法位置。 +- **import**:mysqldao 各文件已含 `global`;redisdao 各文件已含 `global`,对需判断 `redis.Nil` 的文件确认已导入 `github.com/redis/go-redis/v9`(`user.go` 当前未导入,需新增)。 +- **零行为变更**:不改动 SQL/Redis 命令、条件、返回值与错误传播路径,仅叠加追踪。 +- **性能**:Span 创建为轻量操作,DAO 均为单次 I/O,无额外热点开销;不引入 N+1 或重复遍历。 +- **安全**:仅记录 error 对象,不写入用户敏感数据(避免 PII 进入追踪属性)。 + +## 目录结构 + +``` +zt-closet-backend/ +├── internal/ +│ ├── mysqldao/ +│ │ ├── user.go # [MODIFY] UserCreate 已完成;改造 UserUpdateByID/UserGetByID/UserGetByUUID/UserGetByOpenID +│ │ ├── auditlog.go # [MODIFY] ClosetAuditLog* 与 OutfitAuditLog* 全部方法加追踪 +│ │ ├── closet.go # [MODIFY] ClosetShare* 与 ClosetShareItem* 全部方法加追踪 +│ │ ├── clothes.go # [MODIFY] Clothes* 全部方法加追踪 +│ │ ├── collect.go # [MODIFY] 收藏相关 DAO 方法加追踪 +│ │ ├── goods.go # [MODIFY] 商品相关 DAO 方法加追踪 +│ │ ├── horoscope.go # [MODIFY] 星座相关 DAO 方法加追踪 +│ │ ├── like.go # [MODIFY] 点赞相关 DAO 方法加追踪 +│ │ ├── notification.go # [MODIFY] 通知相关 DAO 方法加追踪 +│ │ ├── order.go # [MODIFY] Order* 与 RefundFlowLog* 方法加追踪(含 RowsAffected 分支) +│ │ ├── outfit.go # [MODIFY] 穿搭相关 DAO 方法加追踪 +│ │ ├── quota.go # [MODIFY] Quota*/UserDailyUsage*/QuotaFlowLog* 方法加追踪(含 RowsAffected 分支) +│ │ └── vipflowlog.go # [MODIFY] VIP 流水相关 DAO 方法加追踪 +│ └── redisdao/ +│ ├── auth.go # [MODIFY] AuthTokenSet/Del(管道 Exec)、AuthUserIDBy*(redis.Nil 豁免) +│ ├── closet.go # [MODIFY] 衣柜缓存相关函数加追踪 +│ ├── collect.go # [MODIFY] 收藏缓存相关函数加追踪 +│ ├── horoscope.go # [MODIFY] 星座缓存相关函数加追踪 +│ ├── like.go # [MODIFY] LikeAdd/Remove/IsMember/Count 加追踪 +│ ├── lock.go # [MODIFY] TryLock/Unlock(Lua 脚本)加追踪 +│ ├── notification.go # [MODIFY] 通知缓存相关函数加追踪 +│ ├── outfit.go # [MODIFY] 穿搭缓存相关函数加追踪 +│ ├── quota.go # [MODIFY] Quota 缓存/Lua 脚本相关函数加追踪 +│ └── user.go # [MODIFY] UserInfoSet/Del/Get(Get 需 redis.Nil 豁免,新增 go-redis 导入) +├── global/ +│ └── otel.go # [无需修改] 提供 global.Tracer +``` \ No newline at end of file diff --git a/.codebuddy/plans/fix-14-issues-except-8-and-15_dc316bf3.md b/.codebuddy/plans/fix-14-issues-except-8-and-15_dc316bf3.md new file mode 100644 index 0000000..3dabf5e --- /dev/null +++ b/.codebuddy/plans/fix-14-issues-except-8-and-15_dc316bf3.md @@ -0,0 +1,116 @@ +--- +name: fix-14-issues-except-8-and-15 +overview: 修复代码审查发现的 14 个问题(跳过第 8 项「公开内容自动审核」与第 15 项「CORS 放开」),覆盖:配置加载导致启动崩溃、支付/退款金额校验缺失、点赞收藏越权、CDN 签名弱化、示例配置不一致、删衣不回退额度、穿搭更新清空字段、AI 锁 TTL 过短、收藏列表内存分页、异步 goroutine 无并发上限、单号随机段过短、运势生成无防重、OTel 中间件 nil 防御。 +todos: + - id: config-upload-load + content: 修复配置加载:main.go 补 Upload 段读取、config.example.yaml 修正字段并补 Cos 段、otel.go 加 nil 兜底 + status: completed + - id: order-pay-security + content: order.go 增加支付/退款回调金额校验,并加长订单号/退款单号随机段 + status: completed + - id: interact-visibility + content: 新增穿搭可见性辅助函数,修复点赞/收藏越权校验 + status: completed + - id: upload-clothes-fix + content: upload.go 升级 https+HMAC-SHA256 签名;clothes.go 删除回退额度并加异步并发限流 + status: completed + - id: outfit-airecommend-fix + content: outfit.go 更新防清空字段;airecommend.go 锁 TTL 提高到 180s + status: completed + - id: collect-pagination + content: 收藏列表改 DB 分页:新增 DAO 分页方法并改造 collect.go List + status: completed + - id: fortune-dedupe + content: 运势兜底生成加防重(进程内标记 + Redis SetNX) + status: completed + - id: build-vet-verify + content: 运行 go build ./... 与 go vet ./... 验证全部修复 + status: completed + dependencies: + - config-upload-load + - order-pay-security + - interact-visibility + - upload-clothes-fix + - outfit-airecommend-fix + - collect-pagination + - fortune-dedupe +--- + +## 修复范围 + +基于前序代码审查发现的 14 项问题,按用户指令修复除第 8 项(公开内容自动审核)与第 15 项(CORS 放开)之外的全部问题。 + +## 核心修复项 + +1. **P0 启动崩溃**:`main.go` 的 `setupSetting()` 漏加载 `Upload` 配置段,导致 `global.UploadSetting` 为 nil、启动即 panic。 +2. **P1 安全**: + +- 微信支付回调 `PayNotify` 未校验回调金额是否等于订单应付金额; +- 退款回调 `RefundNotify` 未校验回调退款金额是否等于流水退款金额; +- 点赞/收藏未校验目标穿搭可见性(非本人可操作私有穿搭); +- CDN 图片 URL 使用 md5 签名 + http 明文传输; +- `config.example.yaml` 字段名与代码不一致且缺 `Cos` 段。 + +3. **P2 业务一致性**: + +- 删除衣物不回退上传额度; +- 穿搭更新会无条件用空串清空字段; +- AI 推荐防并发锁 TTL 短于下游 120s 超时; +- 收藏列表全量加载内存分页导致分页口径错乱; +- 异步抠图 goroutine 无并发上限。 + +4. **P3 健壮性**: + +- 订单号/退款单号随机段过短; +- 运势兜底生成无防重; +- OTel 中间件直接解引用 `global.OtelSetting` 缺 nil 防御。 + +## 技术方案 + +### 总体策略 + +沿用现有分层架构(`main` → `routers` → `internal/service` → `internal/mysqldao` / `internal/redisdao` → `pkg/*`),复用已有的 `consumeQuota`/`refundQuota`、`redisdao.TryLock`/`Unlock`、`setIfNotEmpty` 等封装,不做架构变更,以最小侵入方式修复。 + +### 关键决策 + +**1. 配置修复(P0-1 / P1-6 / P3-16)** + +- 在 `main.go` 的 `setupSetting()` 中,于 `ReadSection("Cos", ...)` 后补 `setting.ReadSection("Upload", &global.UploadSetting)`,保留 `MaxSizeMB<=0` 兜底为 20。 +- `config.example.yaml` 的 `Upload.SavePath` 改为 `TmpSavePath`,并补 `Cos` 占位段(字段对齐 `CosSettingS`)。 +- `middleware/otel.go` 的 `GinOTelMiddleware` 增加 nil/空判断,fallback 服务名为 `zt-closet-backend`。 + +**2. 支付安全(P1-2 / P1-3 / P3-13)** + +- `PayNotify`:在幂等锁内改为「先 `OrderGetByOrderNo` 查单 → 校验 `data.Amount.Total == order.PayAmount`(单位均为分)→ 再 `OrderUpdateStatusByOrderNo` 条件流转 → 发放权益」。金额不符仅记 error 日志并 return nil,不流转、不发放。 +- `RefundNotify`:拿到 `flow` 后校验 `data.Amount.Refund == flow.RefundAmount`,不符告警 return nil。 +- `genOrderNo`/`genRefundNo` 随机段由 `[:8]` 加长为 `[:16]`(结果 32 字符,未超微信单号上限)。 + +**3. 越权修复(P1-4)** + +- 在 `service` 包新增共享辅助函数 `isOutfitPublicVisible(o *mysqlmodel.Outfit) bool`(语义同 `outfit.go` `GetByUUID`:`IsPublic==OutfitPublic && AuditStatus==OutfitAuditPass && Status==OutfitStatusNormal`)。 +- `like.go` 的 `getPublicOutfit` 增加 `userID` 入参,非本人且不可见时返回 `errcode.OutfitNoPermission`;同步更新 `LikeCreate`、`GetByTargetID` 两处调用点。 +- `collect.go` 的 `Collect` 对非本人校验可见性。 + +**4. 图片链路与额度(P1-5 / P2-7 / P2-12)** + +- `upload.GetCosUrl`:`http` → `https`;签名由 `md5(secret+"/"+file+ts)` 升级为 `HMAC-SHA256(secret, "/"+file+ts)`,保持 `?sign=xxx&t=ts`。风险:CDN 边缘鉴权算法需同步升级为 HMAC-SHA256,否则图片 403。 +- `clothes.DeleteByUUID`:删除成功后,仅当 `clothes.ImageProcessStatus == ImageProcessSuccess` 时回退 1 个 COS 额度(`refundQuota`),避免与 `markProcessFailed` 重复回退。 +- `clothes.go` 增加包级信号量 `imageProcessSem = make(chan struct{}, 10)`,在 `processImageAsync` 入口获取、`defer` 释放,限制异步并发。 + +**5. 更新与并发(P2-9 / P2-10)** + +- `outfit.UpdateByUUID`:改用 `setIfNotEmpty` 仅写非空字段;`updates` 为空时跳过 update(明细重建逻辑保持不变)。 +- `airecommend.Create` 锁 TTL 由 60s 提高到 180s(大于 tokenhub 客户端 120s 超时,留余量)。 + +**6. 收藏分页(P2-11)** + +- `mysqldao/collect.go` 新增 `CollectListTargetIDsByUserPage(ctx, collectType, userID, page, pageSize) ([]int64, int64, error)`,用 `Order("id DESC")` + `Count` + `Offset/Limit`。 +- `service/collect.go` 的 `List` 改用该分页方法,`total` 取 DAO 计数,消除内存分页与口径错乱。 + +**7. 运势防重(P3-14)** + +- `tokenhub/scheduler.go` 的 `EnsureTodayFortunes` 与 `runFortuneGeneration` 共用「当日」防重:进程内用 `sync.Mutex`/atomic 标记 + 日期判断,跨实例用 `global.RedisEngine.SetNX`(key `ztcloset:lock:fortune:daily:`,TTL 5 分钟),避免 pkg→internal 反向依赖。 + +### 验证方式 + +- `go build ./...` 与 `go vet ./...` 通过;重点回归启动加载、支付回调、点赞/收藏可见性、收藏分页。 \ No newline at end of file diff --git a/.codebuddy/plans/fix-code-review-bugs_a7c936c2.md b/.codebuddy/plans/fix-code-review-bugs_a7c936c2.md new file mode 100644 index 0000000..ceeabd1 --- /dev/null +++ b/.codebuddy/plans/fix-code-review-bugs_a7c936c2.md @@ -0,0 +1,107 @@ +--- +name: fix-code-review-bugs +overview: 修复全量代码审查确认的 5 项高/中优先级业务与技术 bug:支付回调与订单过期/取消竞态、grantVIP 并发续费丢失天数、补偿任务 failed 重置后死循环、补偿定时任务分布式锁误删、中间件 Redis 错误误拒服务。 +todos: + - id: fix-pay-notify-race + content: 修复支付回调竞态:新增微信 CloseOrder 并在 Cancel 关单、PayNotify 支持过期订单补发货 + status: completed + - id: atomic-vip-renewal + content: 新增 UserRenewVIPAtomic 原子续费并改造 grantVIP 防并发丢天数 + status: completed + - id: fix-compensation-scheduler + content: 修复补偿任务 failed 重置死循环与定时任务分布式锁误删 + status: completed + - id: middleware-redis-fallback + content: 改造 JWTAuth 中间件 UserInfoGet 错误回落到数据库避免误拒 + status: completed +--- + +## 产品概述 + +对现有后端进行全量代码审查后的 5 项高/中优先级缺陷修复,核心目标是堵住两个资损漏洞(支付回调竞态、VIP 并发续费丢失),并修复补偿任务死循环、分布式锁误删、鉴权中间件可用性三个技术逻辑缺陷。 + +## 核心功能 + +1. **支付回调与订单过期/取消竞态修复**:取消订单时同步调用微信关单;支付回调在订单被系统过期后仍能流转为已支付并发放权益,避免用户付款但权益不到账。 +2. **VIP 并发续费原子化**:会员续费从「读-改-写」改为单条原子 SQL(CASE 表达式),避免两笔订单并发支付时后写覆盖、丢失续费天数。 +3. **补偿任务重试恢复**:失败补偿任务被重置为待发放时同步清零重试次数,避免因重试次数仍超限而陷入「立即置失败」的死循环。 +4. **补偿定时任务锁安全**:分布式锁释放改为校验持有者 value 后删除,避免任务执行超时后误删其它实例的锁。 +5. **鉴权中间件容错**:Redis 读取用户信息出错时回落到数据库查询,避免 Redis 抖动导致全部鉴权接口被拒绝。 + +## 非目标(本次不修改) + +RefreshToken 轮换未失效旧 access token、微信登录并发可能重复建用户、创建订单落库失败产生孤儿预支付单、空衣橱快照分享仍扣额度、回调报文未截断长度,上述 5 项作为低优先级建议记录,不在本次修复范围内。 + +## 技术栈 + +- 语言与框架:Go 1.27、Gin、GORM(gorm.io/gorm v1.31.2)、go-redis、log/slog、OpenTelemetry +- 微信支付:官方 `github.com/wechatpay-apiv3/wechatpay-go v0.2.21`(已确认 `jsapi.JsapiApiService.CloseOrder` 与 `CloseOrderRequest` 结构) +- 复用现有约定:Service 返回 `(*dto.X, *errcode.Error)`、DAO 返回底层 error、Redis 锁用 `redisdao.TryLock/Unlock`、日志用 `slog` + `global.Tracer` + +## 实现方案 + +### Bug1 支付回调竞态(资损) + +- `pkg/wechat/pay.go` 新增 `CloseOrder(ctx, orderNo string) error`:调用 `jsapi.JsapiApiService{Client: c.client}.CloseOrder(ctx, jsapi.CloseOrderRequest{OutTradeNo: core.String(orderNo), Mchid: core.String(c.mchID)})`,直接返回 err。 +- `internal/service/order.go` `Cancel`:本地条件流转 `pending->cancelled` 成功后,`best-effort` 调用 `wechat.CloseOrder`,失败用 `slog.WarnContext` 记录(不阻断用户取消响应)。 +- `internal/service/order.go` `PayNotify`:将「写支付信息 + 发放权益 + 发订单通知」抽取为可复用私有方法 `finalizePaidOrder(ctx, order, data)`;在 `pending->paid` 流转返回 false 后重新 `OrderGetByOrderNo` 读取订单:若 `OrderStatusExpired` 则改为 `expired->paid` 流转并调用 `finalizePaidOrder`(用户已付款应发货);若 `OrderStatusCancelled` 则记录 `slog.ErrorContext` 告警(用户主动取消却仍支付,人工介入)。 + +### Bug2 grantVIP 原子续费(资损) + +- `internal/mysqldao/user.go` 新增原子方法 `UserRenewVIPAtomic(ctx, userID int64, now int64, memberDays int64, vipType int8) (int64, error)`,用一条 `UPDATE` + `gorm.Expr` 原子更新,返回 RowsAffected: +- `vip_expire = (CASE WHEN vip_expire > ? THEN vip_expire ELSE ? END) + ?`(参数 now, now, memberDays*86400) +- `vip_type = ?`(vipType) +- `vip_start = CASE WHEN vip_type = 1 OR vip_expire <= ? THEN ? ELSE vip_start END`(参数 now, now) +- `internal/service/order.go` `grantVIP` 改造:先 `UserGetByID` 读旧值用于判定 `changeType/newVIPType/vipStart` 与流水 `OldVIPExpire`,再调用 `UserRenewVIPAtomic`,随后 `UserGetByID` 读回真实 `newExpire` 写 VIP 流水(`OldVIPExpire=旧值`、`NewVIPExpire=读回值`),最后失效用户缓存。核心收益:`vip_expire` 的累加在数据库层原子完成,并发不丢天数。 + +### Bug3 补偿任务死循环 + +- `internal/mysqldao/benefit_grant_task.go` `BenefitGrantTaskReset` 的 Updates 增加 `"retry_count": 0`,使 failed 任务重置后真正进入可重试状态。 + +### Bug4 补偿定时任务锁误删 + +- `internal/service/benefit_compensation.go` `runBenefitCompensation` 改用 `redisdao.TryLock(ctx, lockKey, lockVal, time.Minute)` + `defer redisdao.Unlock(context.Background(), lockKey, lockVal)`,`lockVal := genUUID()`,复用现有 lua 校验 value 后才 DEL 的 Unlock 语义。 + +### Bug5 中间件 Redis 错误回落 DB + +- `internal/middleware/lzcwyjwt.go` `JWTAuth` 中 `UserInfoGet` 返回任何错误(`redis.Nil` 或其它)都回落到 `mysqldao.NewUserDao().UserGetByID`;仅 DB 查询也失败时才拒绝,并保留「DB 用户不存在 → UnauthorizedTokenError」「状态非正常 → UserDisabled」逻辑。 + +## 实现注意事项 + +- SQL 一律值绑定,`gorm.Expr` 仅用于表达式片段、参数通过占位符绑定,禁止字符串拼接 SQL。 +- 所有改动保持向后兼容,不引入事务封装或新依赖;`Cancel` 关单与 `finalizePaidOrder` 为增量修改,不影响现有 `RefundNotify` 流程。 +- 日志复用现有 `slog`,回调金额校验、订单归属校验保持不变;不打印 token、支付密钥等敏感信息。 +- `vip_start`/`vip_type` 的 CASE 更新与 `vip_expire` 在同一 UPDATE 中原子完成,避免字段间短暂不一致;`vip_type` 的续费/开通判定沿用现有 `memberDays >= 365` 规则。 + +## 目录结构 + +``` +zt-closet-backend/ +├── pkg/ +│ └── wechat/ +│ └── pay.go # [MODIFY] 新增 CloseOrder 关单方法 +├── internal/ +│ ├── service/ +│ │ ├── order.go # [MODIFY] Cancel 关单、PayNotify 过期/取消竞态处理、grantVIP 原子续费、finalizePaidOrder 抽取 +│ │ └── benefit_compensation.go # [MODIFY] 定时任务锁改用 TryLock/Unlock +│ ├── mysqldao/ +│ │ ├── user.go # [MODIFY] 新增 UserRenewVIPAtomic 原子续费方法 +│ │ └── benefit_grant_task.go # [MODIFY] Reset 增加 retry_count 清零 +│ └── middleware/ +│ └── lzcwyjwt.go # [MODIFY] UserInfoGet 错误回落 DB +``` + +## 关键代码结构 + +``` +// internal/mysqldao/user.go +// UserRenewVIPAtomic 原子续费会员:vip_expire 使用 CASE 表达式累加避免并发丢失, +// 同时原子维护 vip_type 与 vip_start,返回受影响行数。 +func (d *UserDao) UserRenewVIPAtomic(ctx context.Context, userID int64, now int64, memberDays int64, vipType int8) (int64, error) +``` + +``` +// pkg/wechat/pay.go +// CloseOrder 关闭微信侧待支付订单,防止本地取消后用户仍完成支付。 +func CloseOrder(ctx context.Context, orderNo string) error +``` \ No newline at end of file diff --git a/.codebuddy/plans/fix-remaining-issues_32b47301.md b/.codebuddy/plans/fix-remaining-issues_32b47301.md new file mode 100644 index 0000000..fe1a6af --- /dev/null +++ b/.codebuddy/plans/fix-remaining-issues_32b47301.md @@ -0,0 +1,179 @@ +--- +name: fix-remaining-issues +overview: 修复代码审查中除「问题2:唯一约束依赖数据库 Schema」外的全部技术/业务漏洞,共 9 项:退款改人工审核、access token 中间件校验、权益发放补偿流水+定时重试、订单超时过期、refundQuota 检查 RowsAffected、上传 magic bytes 校验、LLM prompt 注入防护、回调原始报文持久化、死代码清理。 +todos: + - id: refund-manual-review + content: 在 order.go 与 wechat/pay.go 实现退款人工审核并持久化支付/退款回调报文 + status: completed + - id: benefit-compensation + content: 新增 benefit_grant_task 表/模型/DAO 与 DDL,改造幂等发放并加补偿定时重试 + status: completed + dependencies: + - refund-manual-review + - id: order-expire-scheduler + content: 在 OrderDao 新增批量过期方法并新增订单超时过期定时任务 + status: completed + dependencies: + - benefit-compensation + - id: token-session-check + content: 在 lzcwyjwt.go 中间件增加 access token 会话存在性校验 + status: completed + - id: quota-refund-rows + content: 修改 QuotaDecreaseUsed 返回影响行数并在 refundQuota 校验,同步检查调用点 + status: completed + - id: security-hardening + content: 在 upload/clothes/tokenhub 增加图片 magic bytes 校验与 prompt 注入清洗 + status: completed + - id: dead-code-cleanup + content: 删除 redisdao/quota.go 中未使用的 QuotaRemainDecr/QuotaRemainGet/脚本 + status: completed +--- + +## 产品概述 + +对现有后端(Go + Gin + GORM + Redis)进行 9 项安全与资损漏洞修复,唯一约束依赖数据库 Schema 一项按用户要求保持不变。核心目标:堵住退款白嫖权益的资损漏洞、让权益发放可补偿、让 access token 可被会话撤销,并补齐订单过期、额度回退一致性、上传内容校验、prompt 注入防护、回调审计与死代码清理。 + +## 核心功能 + +1. 退款改为人工审核:用户申请退款仅创建待审核流水,不再自动调用微信退款;运营人工退款后由回调更新订单状态。 +2. 权益发放补偿:支付成功发放权益失败时写入补偿任务表,后台定时任务幂等重试,超过上限后告警。 +3. access token 会话校验:JWTAuth 中间件校验 token 是否仍存在于 Redis 会话,登出/失效后立即拒绝。 +4. 订单超时自动过期:后台定时任务将超时未支付订单批量置为已过期。 +5. 额度回退一致性:回退配额时校验实际影响行数,无效回退不再误写成功流水。 +6. 上传内容校验:读取文件头 magic bytes 校验真实图片类型,防止伪造扩展名绕过。 +7. LLM prompt 注入防护:清洗拼入 prompt 的衣物名称等用户可控字段。 +8. 回调报文持久化:支付/退款回调原始报文写入订单与退款流水,便于审计对账。 +9. 死代码清理:删除未使用的 Redis 配额函数与脚本。 + +## Tech Stack + +- 语言与框架:Go 1.27、Gin、GORM、go-redis、log/slog、OpenTelemetry +- 复用现有架构:Service 层返回 `(*dto.XxxResponse, *errcode.Error)`;DAO 返回底层 error;定时任务沿用 `pkg/tokenhub/scheduler.go` 的 go func + ticker + Redis 分布式锁模式 + +## Implementation Approach + +### 1. 退款人工审核(问题1) + +`internal/service/order.go` 的 `RefundApply` 删除 `wechat.Refund` 调用与即时成功分支,仅创建 `RefundFlowLog`(`Status: RefundStatusPending`)并返回。保留 `RefundNotify`/`settleRefundSuccess`,运营在微信后台手动退款后由回调完成订单置为已退款、写 `refund_time/refund_amount`、发通知。`RefundResponse.Status` 已支持 0=待处理,无需改 DTO。 + +### 2. 回调报文持久化(问题10) + +`pkg/wechat/pay.go` 的 `VerifyAndDecryptPayNotify`/`VerifyAndDecryptRefundNotify` 中,`notifyHandler.ParseNotifyRequest` 会消费 `request.Body`,需先 `io.ReadAll(request.Body)` 再用 `io.NopCloser(bytes.NewReader(raw))` 恢复,并把 raw 通过返回结构新增的 `Raw string` 字段带回。`order.go` 的 `PayNotify` 将 raw 写入 `orders.callback_raw`,`RefundNotify` 写入 `refund_flow_logs.callback_raw`(字段已存在,仅需落库)。 + +### 3. 权益补偿流水 + 定时重试(问题4) + +新增 `benefit_grant_task` 表(无 AutoMigrate,需提供 DDL)。唯一索引 `(order_no, benefit_type)` 作为幂等凭证,防止重复发放。 + +发放改造为幂等流程: + +1. 对每个权益,先尝试 INSERT 一条 `status=pending` 的补偿任务记录; +2. INSERT 冲突说明该权益已有记录:查询状态,`success` 跳过,`pending` 交由定时任务,`failed` 重置为 pending; +3. INSERT 成功则执行实际发放(VIP 延长/额度增加),成功后置 `success`,失败保留 `pending` 并写 `last_error`; +4. 定时任务(单实例 Redis 锁)扫描 `pending` 且 `retry_count < max_retry` 的任务,重试前先查 `quota_flow_logs`/`vip_flow_logs` 中是否已有同 `order_no` 的发放流水,已发放则直接置 `success`,未发放才执行发放,从而把「发放成功但未置 success」崩溃窗口的重复发放风险降至最低; +5. 重试失败 `retry_count+1`,超过 `max_retry` 置 `failed` 并记录 Error 告警日志。 + +### 4. 订单超时过期(问题5) + +`internal/mysqldao/order.go` 新增批量条件更新方法(`WHERE order_status=待支付 AND expire_time < now` 置为已过期),条件更新天然幂等、多实例安全;新增定时任务每 1 分钟执行一次,`main.go` 启动。 + +### 5. access token 会话校验(问题3) + +`internal/middleware/lzcwyjwt.go` 的 `JWTAuth` 在 `ParseAccessToken` 成功后,调用 `redisdao.AuthUserIDByAccess`:`redis.Nil` 或 uid 与 claims 不一致 → 拒绝;Redis 其他错误 → warn 日志并放行(与现有 UserInfoGet 降级风格一致)。 + +### 6. refundQuota 检查影响行数(问题7) + +`internal/mysqldao/quota.go` 的 `QuotaDecreaseUsed` 改为返回 `(bool, error)`;`internal/service/service.go` 的 `refundQuota` 在 `RowsAffected==0` 时记录 warn 且不写成功流水;同步检查 `internal/service/clothes.go` 中的调用点。 + +### 7. 上传 magic bytes 校验(问题8) + +`pkg/upload/upload.go` 新增基于文件头字节的真实类型识别(jpeg/png/webp/gif);`internal/service/clothes.go` 的 `Upload` 在已读取的 `imageData` 之后、扣额度之前校验,不匹配返回 `errcode.UploadFileExtNotAllow`。 + +### 8. LLM prompt 注入防护(问题9) + +`pkg/tokenhub/outfit_recommend.go` 新增 `sanitizePromptField` 辅助(截断长度、移除换行与控制字符、过滤可能改变指令语义的字符),应用到 `FormatClothesInfo` 的每个字段输出。 + +### 9. 死代码清理(问题11) + +删除 `internal/redisdao/quota.go` 中未使用的 `QuotaRemainDecr`、`QuotaRemainGet`、`quotaRemainDecrScript`;保留仍在使用的 `quotaDailyIncrScript`。 + +## Architecture Design + +补偿发放幂等状态流转: + +```mermaid +flowchart TD + A[支付成功] --> B[逐权益尝试 INSERT 补偿任务] + B -->|INSERT 成功| C[执行发放] + B -->|唯一冲突| D{查询现有状态} + D -->|success| E[跳过] + D -->|pending| F[等待定时任务] + D -->|failed| G[重置 pending 后走重试] + C -->|成功| H[置 success] + C -->|失败| I[保留 pending 写 last_error] + I --> J[定时任务扫描 pending] + J --> K{查发放流水是否已存在} + K -->|已存在| H + K -->|不存在| C + J -->|retry 超限| L[置 failed 并告警] +``` + +## Directory Structure + +``` +zt-closet-backend/ +├── internal/ +│ ├── service/ +│ │ ├── order.go # [MODIFY] 退款改人工审核;PayNotify/RefundNotify 写回调 raw;grantOrderBenefits/grantVIP 幂等改造 +│ │ ├── benefit_compensation.go # [NEW] 权益补偿定时任务(扫描 pending、幂等重试、超限告警) +│ │ ├── order_expire.go # [NEW] 订单超时过期定时任务(每 1 分钟条件更新) +│ │ └── service.go # [MODIFY] refundQuota 检查 RowsAffected +│ ├── mysqlmodel/ +│ │ └── benefit_grant_task.go # [NEW] 补偿任务模型(唯一索引 order_no+benefit_type) +│ ├── mysqldao/ +│ │ ├── benefit_grant_task.go # [NEW] 补偿任务 DAO(插入/查询/条件抢占/状态更新) +│ │ ├── order.go # [MODIFY] 新增批量过期方法 OrderExpireByTime +│ │ └── quota.go # [MODIFY] QuotaDecreaseUsed 返回 (bool, error) +│ ├── middleware/ +│ │ └── lzcwyjwt.go # [MODIFY] JWTAuth 增加 access token 会话校验 +│ └── redisdao/ +│ └── quota.go # [MODIFY] 删除未使用的 QuotaRemainDecr/QuotaRemainGet/脚本 +├── pkg/ +│ ├── wechat/ +│ │ └── pay.go # [MODIFY] 验签前捕获 raw 并在返回结构新增 Raw 字段 +│ ├── upload/ +│ │ └── upload.go # [MODIFY] 新增 magic bytes 真实图片类型校验 +│ └── tokenhub/ +│ └── outfit_recommend.go # [MODIFY] 新增 sanitizePromptField 并应用到 FormatClothesInfo +├── migrations/ +│ └── benefit_grant_task.sql # [NEW] 补偿任务表 DDL(含唯一索引) +└── main.go # [MODIFY] 启动权益补偿与订单过期两个定时任务 +``` + +## Key Code Structures + +``` +// internal/mysqlmodel/benefit_grant_task.go +type BenefitGrantTask struct { + Model + UserID int64 // 用户ID + OrderNo string // 商户订单号(与 benefit_type 组成唯一索引) + GoodsID int64 // 商品ID + BenefitType int8 // 1=vip 2=cos 3=ai 4=closet_share 5=outfit_share + Amount int64 // 发放数量 + Status int8 // 0=pending 1=success 2=failed + RetryCount int // 已重试次数 + MaxRetry int // 最大重试次数 + LastError string // 最近一次失败原因 +} + +// internal/mysqldao/quota.go +func (d *QuotaDao) QuotaDecreaseUsed(ctx context.Context, userID int64, quotaType int8, n int64) (bool, error) +``` + +## Implementation Notes + +- 幂等是补偿重试的关键:所有发放先落 `benefit_grant_task` 唯一记录,重试前查 `quota_flow_logs`/`vip_flow_logs`(含 `order_no`)避免重复发放。 +- 定时任务必须带 Redis 分布式锁与 ctx 超时,避免多实例重复执行;沿用现有 `slog` + `global.Tracer` 记录日志与 trace。 +- 回调 raw 落库注意长度限制(订单/退款流水字段类型),必要时截断;验签失败时不落库。 +- SQL 一律值绑定,退款申请与发放不新增越权路径;prompt 清洗与 magic bytes 校验均为输入侧防护,不得引入 panic。 +- 无 AutoMigrate,`migrations/benefit_grant_task.sql` 需在部署时手动执行。 \ No newline at end of file diff --git a/.codebuddy/plans/fix-second-review-bugs_4bdf0ac1.md b/.codebuddy/plans/fix-second-review-bugs_4bdf0ac1.md new file mode 100644 index 0000000..a82b79c --- /dev/null +++ b/.codebuddy/plans/fix-second-review-bugs_4bdf0ac1.md @@ -0,0 +1,157 @@ +--- +name: fix-second-review-bugs +overview: 修复第二轮全量代码审查确认的 8 项问题:RefreshToken 未吊销旧 access token(安全)、fortune 定时任务无锁/锁误删、收藏点赞并发重复、衣物删除额度泄漏、资料无校验、AI 明细落库失败被吞、缓存 key 无 TTL。 +todos: + - id: fix-refresh-token-revocation + content: 修复 RefreshToken 未吊销旧 access token:DTO 加字段并在服务层精确删除旧会话 + status: completed + - id: fix-fortune-scheduler-lock + content: 统一运势生成分布式锁为 TryLock/Unlock,修复每日无锁与 Ensure 锁误删 + status: completed + - id: fix-collect-like-idempotent + content: 新增 collect/like 唯一索引迁移并用 upsert 改造收藏点赞写入 + status: completed + - id: fix-clothes-quota-refund + content: 修复衣物删除与处理失败的额度回退闭环,防止泄漏与双重回退 + status: completed + - id: fix-profile-validation + content: 为 ProfileUpdate 增加昵称、性别、生日、头像字段校验 + status: completed + - id: fix-ai-items-error + content: AI 推荐明细落库失败时回滚穿搭并回退额度 + status: completed + - id: fix-cache-ttl + content: 为通知未读数与收藏点赞 Redis 集合增加 TTL + status: completed +--- + +## 产品概述 + +对全量代码审查确认的 8 项业务/技术漏洞进行修复,覆盖安全(token 吊销)、并发(定时任务锁、收藏点赞幂等)、业务一致性(额度回退、明细落库)、数据校验与缓存健壮性。所有修复基于当前代码真实状态,不触碰前几轮已完成的修复(支付回调竞态、VIP 原子续费、补偿任务锁、中间件 Redis 回落等)。 + +## 核心功能 + +1. **RefreshToken 轮换吊销旧 access token**:刷新令牌时同步删除旧 access token 会话,使旧 token 立即失效,不再等到自然过期。 +2. **每日运势生成加分布式锁**:多实例部署时每天 0 点仅一个实例生成运势,且锁释放改为校验持有者,杜绝误删。 +3. **收藏/点赞并发幂等**:通过数据库唯一索引 + upsert,消除并发双击导致的重复记录与计数虚高。 +4. **衣物额度回退闭环**:统一「失败即回退」,处理中删除也回退额度,并防止异步失败路径重复回退。 +5. **用户资料字段校验**:昵称长度、性别枚举、生日范围、头像长度校验,拦截非法输入。 +6. **AI 推荐明细落库失败处理**:明细批量创建失败时删除已建穿搭并回退额度,避免空推荐。 +7. **缓存 key 增加 TTL**:通知未读数与收藏/点赞集合设置过期时间,避免弱一致长期漂移。 + +## 非目标(本次不修改) + +前几轮已完成修复的支付回调竞态、VIP 并发续费、benefit_compensation 锁、中间件 Redis 回落、权益补偿幂等、订单过期、退款人工审核、图片 magic bytes、prompt 注入清洗等,均保持不动。微信登录并发建用户、订单孤儿预支付单、空衣橱快照仍扣额度、回调报文未截断等历史已知项本次仍不处理。 + +## 技术栈 + +- 语言与框架:Go、Gin、GORM(gorm.io/gorm v1.31.2 + gorm.io/gorm/clause)、go-redis、log/slog +- 复用现有约定:Service 返回 `(*dto.X, *errcode.Error)`、DAO 返回底层 error、分布式锁统一 `redisdao.TryLock/Unlock`、SQL 一律 gorm 占位符值绑定、日志用 `slog` + +## 实现方案 + +### H1 RefreshToken 吊销旧 access token + +- `internal/dto/user.go` `UserRefreshTokenRequest` 新增可选字段 `AccessToken string json:"access_token"`(不加 `binding:"required"`,向前兼容)。 +- `internal/service/user.go` `RefreshToken`:若 `request.AccessToken != ""`,先用 `redisdao.AuthUserIDByAccess` 校验其归属 `uid == claims.UserID`,不匹配返回 `UnauthorizedTokenError`;随后 `AuthTokenDel(ctx, claims.UserID, request.AccessToken, request.RefreshToken)` 精确删除旧 access + refresh 会话。为空时保持现状仅删 refresh(记录 warn)。 + +### M1/M2 运势生成分布式锁(合并于 scheduler.go) + +- `pkg/tokenhub/scheduler.go` 新增辅助函数 `runFortuneGenerationWithLock`:内部生成 `lockKey = "ztcloset:lock:fortune:daily:" + today`、`lockVal = uuid.NewString()`,`redisdao.TryLock(ctx, lockKey, lockVal, 5*time.Minute)` 失败即返回,`defer redisdao.Unlock(context.Background(), lockKey, lockVal)`,再调 `runFortuneGeneration`。 +- `StartFortuneScheduler` 每日循环与 `EnsureTodayFortunes` 均改调该带锁辅助函数;删除 `EnsureTodayFortunes` 中现有的 `SetNX + 无条件 Del`。 +- 该文件已 import `internal/mysqldao`,按 Go internal 规则可 import `internal/redisdao`;新增 import `github.com/google/uuid`。 + +### M3 收藏/点赞并发幂等 + +- 新增 `migrations/collect_like_unique.sql`:`collects` 加 `UNIQUE KEY uk_collect (collect_type, target_id, user_id)`,`likes` 加 `UNIQUE KEY uk_like (like_type, target_id, user_id)`。DDL 前先清理历史重复数据(保留最小 id)。 +- `internal/mysqldao/collect.go` 新增 `CollectUpsertOn`:用 `clause.OnConflict{Columns: [collect_type,target_id,user_id], DoUpdates: AssignmentColumns(["status"])}` 的 `Create`。 +- `internal/mysqldao/like.go` 新增 `LikeUpsertOn`,语义同上。 +- `internal/service/collect.go` `Collect` 的 `ActionDo` 分支与 `internal/service/like.go` `LikeCreate` 的 `ActionDo` 分支改为直接调用 upsert(去掉 read-then-write),`ActionCancel` 保持现有查询后更新(幂等无害)。 + +### L1 衣物额度回退闭环 + +- `internal/service/clothes.go`: +- 分类失败分支(原仅置 Failed 不回退)补 `refundQuota`,统一「失败即回退」。 +- 新增辅助 `refundIfExists(ctx, userID, clothesUUID, reason)`:先 `ClothesGetByUUID`(软删除自动过滤),仅当衣物仍存在时才 `refundQuota`,已删除则跳过。 +- `markProcessFailed` 改用 `refundIfExists` 替代无条件 `refundQuota`,防止「处理中删除已回退 + 异步失败再回退」双重回退。 +- `DeleteByUUID` 回退条件由 `== ImageProcessSuccess` 改为 `!= ImageProcessFailed`(成功与排队/抠图/分类中等处理中状态均回退;失败状态因已回退而不回退)。 + +### L2 ProfileUpdate 字段校验 + +- `internal/service/user.go` `ProfileUpdate` 增加校验: +- `Username`:trim 后按 rune 长度 ≤ 32,超长返回 `InvalidParams`。 +- `Avatar`:长度 ≤ 500。 +- `Sex`:非 0 时必须为 `SexMale(1)` 或 `SexFemale(2)`。 +- `BirthDay`:必须 `> 0` 且 `<= nowUnix()`。 +- 校验失败返回 `errcode.InvalidParams.WithDetails(...)`;新增 import `strings`。 + +### L3 AI 推荐明细落库失败处理 + +- `internal/service/airecommend.go` 第 119 行附近:`OutfitItemCreateBatch` 返回 error 时,先 `OutfitDeleteByUUID` 软删除已建穿搭,再 `return nil, fail(errcode.OutfitCreateFailed, "穿搭明细落库失败")`(`fail` 闭包会回退 AI 额度并写失败流水)。 + +### L4 缓存 key 加 TTL + +- `internal/redisdao/notification.go`:新增 `notificationUnreadIncrScript`(INCR 后首次 EXPIRE),`NotificationUnreadIncr` 改用脚本,TTL 常量 `7 * 24 * time.Hour`。 +- `internal/redisdao/collect.go` `CollectAdd`:`SADD` 后 `Expire(key, 7*24h)`(best-effort)。 +- `internal/redisdao/like.go` `LikeAdd`:同上加 `Expire`。`Remove` 系列不刷新 TTL。 + +## 实现注意事项 + +- 安全:M3 的 upsert 依赖唯一索引,DDL 需部署时手动执行;若 `collects/likes` 已存在重复数据,必须先清理再建索引(SQL 中提供 `DELETE` 保留最小 id 的清理语句)。 +- 一致性:L1 的核心是「失败即回退 + 删除回退 + 已删除不回退」三段式闭环,`refundIfExists` 是防止双重回退的关键守卫,不可省略。 +- 向后兼容:H1 的 `AccessToken` 为可选字段,老客户端不传不报错,仅保留原行为;新客户端传则立即吊销旧 token。 +- 影响范围:所有改动为增量修改,不引入事务封装或新依赖;不改动既有的支付/订单/额度/补偿相关已修复逻辑。 +- 日志:沿用 `slog`,H1 旧 token 未携带时用 `WarnContext` 提示,不打印 token 明文与密钥。 + +## 目录结构 + +``` +zt-closet-backend/ +├── internal/ +│ ├── dto/ +│ │ └── user.go # [MODIFY] UserRefreshTokenRequest 新增 AccessToken 字段 +│ ├── service/ +│ │ ├── user.go # [MODIFY] RefreshToken 吊销旧 access;ProfileUpdate 字段校验 +│ │ ├── collect.go # [MODIFY] ActionDo 改 upsert 防并发重复 +│ │ ├── like.go # [MODIFY] ActionDo 改 upsert 防并发重复 +│ │ ├── clothes.go # [MODIFY] 删除/失败额度回退闭环 +│ │ └── airecommend.go # [MODIFY] 明细落库失败回滚处理 +│ ├── mysqldao/ +│ │ ├── collect.go # [MODIFY] 新增 CollectUpsertOn +│ │ └── like.go # [MODIFY] 新增 LikeUpsertOn +│ └── redisdao/ +│ ├── notification.go # [MODIFY] 未读数 key 加 TTL +│ ├── collect.go # [MODIFY] 收藏集合加 TTL +│ └── like.go # [MODIFY] 点赞集合加 TTL +├── pkg/ +│ └── tokenhub/ +│ └── scheduler.go # [MODIFY] 运势生成统一 TryLock/Unlock 锁 +└── migrations/ + └── collect_like_unique.sql # [NEW] collects/likes 唯一索引 + 历史重复清理 +``` + +## 关键代码结构 + +``` +// internal/mysqldao/collect.go +// CollectUpsertOn 收藏幂等写入:唯一索引冲突时仅置 status=1,防并发重复插入。 +func (d *CollectDao) CollectUpsertOn(ctx context.Context, collect *mysqlmodel.Collect) error + +// internal/mysqldao/like.go +// LikeUpsertOn 点赞幂等写入:唯一索引冲突时仅置 status=1,防并发重复插入。 +func (d *LikeDao) LikeUpsertOn(ctx context.Context, like *mysqlmodel.Like) error +``` + +```sql +-- migrations/collect_like_unique.sql +-- 1) 清理历史重复(保留最小 id) +DELETE c1 FROM collects c1 JOIN collects c2 + ON c1.collect_type=c2.collect_type AND c1.target_id=c2.target_id AND c1.user_id=c2.user_id + AND c1.id > c2.id; +ALTER TABLE collects ADD UNIQUE KEY uk_collect (collect_type, target_id, user_id); + +DELETE l1 FROM likes l1 JOIN likes l2 + ON l1.like_type=l2.like_type AND l1.target_id=l2.target_id AND l1.user_id=l2.user_id + AND l1.id > l2.id; +ALTER TABLE likes ADD UNIQUE KEY uk_like (like_type, target_id, user_id); +``` \ No newline at end of file diff --git a/.codebuddy/plans/matting-client-and-test_a3ad5696.md b/.codebuddy/plans/matting-client-and-test_a3ad5696.md new file mode 100644 index 0000000..5de2447 --- /dev/null +++ b/.codebuddy/plans/matting-client-and-test_a3ad5696.md @@ -0,0 +1,99 @@ +--- +name: matting-client-and-test +overview: 实现 pkg/matting 抠图服务 Go 客户端,补齐配置读取并把端口对齐到 8001,然后在本地安装依赖、下载 BiRefNet_lite 模型并启动服务,用 image.png 跑通真实抠图测试。 +todos: + - id: implement-matting-client + content: 实现 pkg/matting 客户端 Matting 与 HealthCheck 方法 + status: completed + - id: wire-matting-config + content: 接线配置:main.go 读取 MattingModel,config.yaml 端口改 8001 + status: completed + - id: write-matting-test + content: 编写 matting_test.go,读取 image.png 并断言 PNG 结果 + status: completed + dependencies: + - implement-matting-client + - id: prepare-matting-service + content: 创建 venv 安装依赖并启动 8001 抠图服务 + status: completed + - id: run-real-matting-test + content: 运行 go test 完成 image.png 真实抠图验证 + status: completed + dependencies: + - write-matting-test + - prepare-matting-service +--- + +## 用户需求 + +完成 Go 后端 `pkg/matting` 抠图客户端封装,并用仓库根目录下的 `image.png` 进行真实抠图测试。 + +## 已确认的补充约束 + +- 测试方式:本地启动真实抠图服务跑通(安装 torch 等依赖、下载 BiRefNet_lite 模型、启动 8001 服务、用 image.png 真实抠图验证)。 +- 服务端口:将 `config.yaml` 中 `MattingModel.Port` 改为 8001,与 Python 服务默认端口一致。 + +## 核心功能 + +- 实现 `pkg/matting` 客户端,封装对 Python 抠图服务 `POST /matting` 的 multipart 上传调用,返回透明底 PNG 字节。 +- 提供客户端初始化(从全局配置读取 Host/Port)与可指定地址的构造方式,便于测试解耦。 +- 补充主程序配置加载,使 `MattingModel` 配置真正生效。 +- 编写测试用例,读取 `image.png` 作为输入,验证返回结果为合法 PNG 图片。 +- 在本地搭建并启动 Python 抠图服务,完成端到端真实抠图验证。 + +## 技术栈 + +- 语言:Go(复用现有 `net/http`、`mime/multipart`、`context` 标准库)。 +- 参考实现:`pkg/tokenhub/client.go`(Client 结构体 + NewClient 从 global 配置读取 + 带 Timeout 的 httpClient)。 +- 服务端:已有 `thirdparty/matting_server/main.py`(FastAPI,`POST /matting` multipart 字段名 `file`,返回 `image/png`)。 + +## 实现方案 + +### 客户端设计(新建 `pkg/matting/matting.go`) + +- 采用与 `tokenhub.Client` 一致的轻量客户端模式,不引入第三方 HTTP 库。 +- `Client` 持有 `baseURL` 与 `httpClient`(Timeout 建议 120s,抠图为 CPU/GPU 推理耗时较长)。 +- `NewClient()` 从 `global.MattingModelSetting.Host/Port` 拼接 `http://host:port`。 +- `NewClientWithAddr(host string, port int)` 便于测试与解耦,避免依赖全局配置初始化。 +- `Matting(ctx, imageData []byte) ([]byte, error)`:构造 multipart/form-data,字段名 `file`(与 Python 端 `UploadFile = File(...)` 对齐),发送 POST 到 `/matting`,读取并返回响应体;非 200 时返回带状态码与响应体摘要的错误。 +- `HealthCheck(ctx) error`:GET `/healthz`,用于服务启动后确认就绪与测试前置检查。 + +### 配置接线 + +- `main.go` 的 `setupSetting()` 补 `setting.ReadSection("MattingModel", &global.MattingModelSetting)`,避免 `global.MattingModelSetting` 为 nil 导致 `NewClient` panic。 +- `configs/config.yaml`:`MattingModel.Port` 由 80 改为 8001;移除结构体不支持的 `retry` 字段。 +- `configs/config.example.yaml`:补 `MattingModel`(Host 127.0.0.1 / Port 8001)示例段。 + +### 测试设计(新建 `pkg/matting/matting_test.go`) + +- 读取 `../../image.png`(测试文件位于 `pkg/matting/` 下)。 +- 使用 `NewClientWithAddr("127.0.0.1", 8001)` 调用 `Matting`。 +- 断言返回字节非空、以 PNG 魔数 `\x89PNG` 开头;将结果写入 `t.TempDir()` 下的文件便于人工核验。 +- 可选:先调用 `HealthCheck` 确认服务就绪,失败时给出明确提示。 + +### 环境准备与真实跑通 + +- 使用独立 venv 隔离 Python 依赖,`pip install -r thirdparty/matting_server/requirements.txt`(CPU 版 torch)。 +- 启动 `thirdparty/matting_server/main.py`(监听 8001),等待 BiRefNet_lite 模型下载与加载完成。 +- 执行 `go test ./pkg/matting/ -run TestMatting -v` 完成真实抠图验证。 + +## 关键代码结构 + +``` +type Client struct { + baseURL string + httpClient *http.Client +} + +func NewClient() *Client +func NewClientWithAddr(host string, port int) *Client +func (c *Client) Matting(ctx context.Context, imageData []byte) ([]byte, error) +func (c *Client) HealthCheck(ctx context.Context) error +``` + +## 实现注意事项 + +- multipart 写入需正确 `Close()` 并设置 `Content-Type: multipart/form-data; boundary=...`。 +- 请求与响应体需 `defer resp.Body.Close()`,避免连接泄漏。 +- 错误信息应包含 HTTP 状态码与响应体摘要(截断),便于定位模型侧错误,避免打印大体积二进制。 +- 不引入与现有项目冲突的新架构或第三方依赖,保持 `pkg` 包风格一致。 \ No newline at end of file diff --git a/.codebuddy/plans/matting_server_python_run_5105572d.md b/.codebuddy/plans/matting_server_python_run_5105572d.md new file mode 100644 index 0000000..3c55195 --- /dev/null +++ b/.codebuddy/plans/matting_server_python_run_5105572d.md @@ -0,0 +1,61 @@ +--- +name: matting_server_python_run +overview: 与分类服务一致,为抠图服务(BiRefNet)补齐纯 Python 独立运行配套:新增 requirements.txt 依赖清单,并在 main.py 末尾添加 uvicorn 启动入口(端口 8001),使 `python main.py` 一条命令即可启动。 +todos: + - id: create-matting-requirements + content: 新建 thirdparty/matting_server/requirements.txt 依赖清单并添加 GPU 安装与模型手动下载说明注释 + status: completed + - id: add-matting-start-entry + content: 在 main.py 末尾追加 uvicorn 启动入口(端口 8001)并做语法验证 + status: completed +--- + +## 用户需求 + +参照刚完成的分类服务改造,将抠图服务(BiRefNet 人像/衣物抠图)同样改造为纯 Python 独立运行,完全不依赖 Go 项目: + +- 新增 `requirements.txt` 依赖清单 +- 在 `main.py` 末尾添加启动入口,使 `python main.py` 一条命令即可启动服务 + +不要求本地测试脚本和 README。 + +## 核心功能 + +- **依赖清单**:声明 torch、torchvision、transformers、fastapi、uvicorn、pillow 六个依赖及版本下限,并注明 GPU 用户需单独安装 CUDA 版 torch、BiRefNet_lite 模型文件需手动下载到 ./model/ 目录 +- **一键启动**:`python main.py` 直接以 0.0.0.0:8001 启动 FastAPI 服务,无需 `uvicorn main:app` 命令 + +## 技术方案 + +### 实现方式 + +1. 新建 `thirdparty/matting_server/requirements.txt`,列出运行所需的 6 个第三方依赖,采用宽松下限版本避免锁死小版本: + +- torch>=2.0.0、torchvision>=0.15.0、transformers>=4.44.0、fastapi>=0.100.0、uvicorn>=0.23.0、pillow>=9.5.0 +- 与分类服务的差异:多一个 `torchvision`(transforms/ToPILImage 用到);`transformers` 下限为 4.44.0(`AutoModelForImageSegmentation` 是该版本才引入的类,不能照抄分类服务的 4.30.0) +- 文件内注释说明两点:PyPI 默认安装 CPU 版 torch,GPU 用户需按 PyTorch 官网命令单独安装对应 CUDA 版本;模型代码 `local_files_only=True`,需先将 BiRefNet_lite 模型文件手动下载放到 `./model/` 目录 + +2. 修改 `main.py` 末尾(第 61-64 行 `/healthz` 路由之后)追加启动入口: + +```python +if **name** == "**main**": +import uvicorn + +uvicorn.run(app, host="0.0.0.0", port=8001) +``` + +- **关键点**:BiRefNet 模型在模块 import 时于模块级加载(`model`、`transform` 为模块级变量),必须直接传 `app` 对象调用 `uvicorn.run(app, ...)`,不能使用 `"main:app"` 字符串加 reload 方式,否则会导致模型重复加载 +- uvicorn 放在入口内局部 import,保持顶部 import 区原结构不变,改动最小化 +- 端口选用 8001,与分类服务的 8000 错开避免冲突(Go 侧目前无抠图服务端口配置,端口可自由选定) + +### 验证方式 + +- `python3 -m py_compile main.py` 确认语法无误,验证后清理 `__pycache__` +- 交付后运行验证:`pip install -r requirements.txt && python main.py`,访问 `http://localhost:8001/healthz` 返回 `{"status": "ok"}` + +### 目录结构 + +``` +thirdparty/matting_server/ +├── main.py # [MODIFY] 末尾追加 __main__ 启动入口,python main.py 直接启动(端口 8001) +└── requirements.txt # [NEW] 依赖清单(torch/torchvision/transformers/fastapi/uvicorn/pillow + GPU 与模型下载说明注释) +``` \ No newline at end of file diff --git a/.codebuddy/plans/model-call-retry_5868b00a.md b/.codebuddy/plans/model-call-retry_5868b00a.md new file mode 100644 index 0000000..d0c0f14 --- /dev/null +++ b/.codebuddy/plans/model-call-retry_5868b00a.md @@ -0,0 +1,87 @@ +--- +name: model-call-retry +overview: 为抠图(Matting)与分类(Classification)两个模型调用增加可配置的自动重试机制,默认最大重试 3 次;配置项分别加到 MattingModel 与 ClassificationModel 配置段下,并在衣物异步处理流程中接入重试逻辑。 +todos: + - id: add-retry-config + content: 在 section.go 结构体及 config.yaml、config.example.yaml 中为 MattingModel/ClassificationModel 增加 MaxRetry 配置 + status: completed + - id: default-retry-value + content: 在 main.go 读取配置后对两个模型 MaxRetry + status: completed + dependencies: + - add-retry-config +--- + +## 产品概述 + +为衣物图片处理流水线中的抠图(Matting)与分类(Classification)两个模型调用增加自动重试能力,降低下游模型服务偶发失败导致的衣物处理失败率。重试次数通过配置文件分别控制,默认最大重试 3 次。 + +## 核心功能 + +- 抠图调用失败时自动重试,默认最大重试 3 次(总尝试 4 次) +- 分类调用失败时自动重试,默认最大重试 3 次(总尝试 4 次) +- 重试次数分别配置在 `MattingModel` 与 `ClassificationModel` 两个配置段下,配置缺失或小于等于 0 时兜底为 3 +- 全部重试失败后维持现有失败处理:抠图失败回退上传额度并标记失败;分类失败保留已抠图结果(processed_image)并标记失败,便于用户手动补全 +- 删除处理中衣物不回退额度的既有防滥用设计保持不变(不做修改) + +## 技术栈 + +- Go 1.27(使用泛型、`log/slog`、`context` + `time.After`) +- 配置:viper(复用现有 `setting.ReadSection` 读取模式) +- 日志:`log/slog`(复用现有 `slog.WarnContext` / `slog.ErrorContext`) + +## 实现方案 + +### 总体策略 + +复用现有配置读取与兜底模式,在配置结构体、配置文件中增加 `MaxRetry` 字段;在异步处理函数 `processImageAsync` 中新增一个泛型重试辅助函数 `retryCall`,包裹抠图与分类两处调用,实现失败自动重试,失败分支逻辑保持不变。 + +### 关键设计 + +1. **配置层(最小改动)** + +- `pkg/setting/section.go` 的 `MattingModelSettingS`、`ClassificationModelSettingS` 各新增 `MaxRetry int` 字段。 +- `configs/config.yaml`(实际配置)与 `configs/config.example.yaml`(示例配置)的 `MattingModel`、`ClassificationModel` 段各新增 `MaxRetry: 3`。 +- `main.go` 在读取 `MattingModel`、`ClassificationModel` 后,对 `MaxRetry <= 0` 兜底为 3,与现有 `MaxSizeMB <= 0` 兜底 20、`Model == ""` 兜底 `hy4` 的模式保持一致。 + +2. **重试层(泛型辅助函数)** + +- 在 `internal/service/clothes.go` 新增 `retryCall[T any]` 泛型函数:循环尝试 `maxRetry+1` 次;每次失败记录 `slog.WarnContext`;重试间隔使用 `select` + `time.After(1s)` + `ctx.Done()`,保证 ctx 到期时提前终止。 +- 抠图与分类调用分别用 `retryCall` 包裹,`maxRetry` 分别取 `global.MattingModelSetting.MaxRetry` 与 `global.ClassificationModelSetting.MaxRetry`。 +- `fn` 闭包捕获外部 `ctx`,每次调用内部会重建 multipart body,无需额外重置状态。 + +3. **失败分支保持不变** + +- 抠图全部重试失败后仍调用 `markProcessFailed`(标记失败 + 回退上传额度)。 +- 分类全部重试失败后仍保留 `processed_image` 并标记 `ImageProcessFailed`(不回退额度,允许用户手动补全)。 + +### 关键代码结构 + +``` +// retryCall 对 fn 进行最多 maxRetry 次额外重试(总尝试 maxRetry+1 次)。 +// 重试间隔 1s;ctx 到期则提前终止并返回 ctx.Err()。 +func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func() (T, error)) (T, error) + +// 调用示例 +processedData, err := retryCall(ctx, global.MattingModelSetting.MaxRetry, "matting", func() ([]byte, error) { + return mattingClient.Matting(ctx, imageData) +}) +``` + +## 实现注意 + +- **MaxRetry 语义**:失败后的额外重试次数,总尝试次数 = MaxRetry + 1;默认 3 即总尝试 4 次。 +- **超时预算**:异步 ctx 总超时为 3 分钟,单次 HTTP 客户端超时为 120s。`retryCall` 通过 `ctx.Done()` 检查避免超时后继续重试;瞬时失败(连接拒绝、偶发非 200)通常快速返回,3 分钟预算足够。 +- **并发限流不受影响**:`imageProcessSem` 信号量在 `processImageAsync` 入口已获取,重试发生在同一 goroutine 内,不会放大并发。 +- **blast radius 控制**:仅新增配置字段与重试辅助函数,不修改 `DeleteByUUID` 额度回退逻辑;`config.yaml` 中重复的 `UserDefaultQuota` 段为既有问题,本次不处理。 + +## 目录结构 + +``` +zt-closet-backend/ +├── pkg/setting/section.go # [MODIFY] MattingModelSettingS、ClassificationModelSettingS 各新增 MaxRetry int 字段 +├── configs/config.yaml # [MODIFY] MattingModel、ClassificationModel 段各新增 MaxRetry: 3 +├── configs/config.example.yaml # [MODIFY] MattingModel、ClassificationModel 段各新增 MaxRetry: 3 +├── main.go # [MODIFY] 读取 MattingModel、ClassificationModel 后对 MaxRetry<=0 兜底为 3 +└── internal/service/clothes.go # [MODIFY] 新增 retryCall 泛型辅助函数,抠图/分类调用接入重试 +``` \ No newline at end of file diff --git a/.codebuddy/plans/models_auto_download_fallback_2b158447.md b/.codebuddy/plans/models_auto_download_fallback_2b158447.md new file mode 100644 index 0000000..ac55ee6 --- /dev/null +++ b/.codebuddy/plans/models_auto_download_fallback_2b158447.md @@ -0,0 +1,84 @@ +--- +name: models_auto_download_fallback +overview: 为分类服务(Fashion-CLIP)与抠图服务(BiRefNet)统一改造模型加载逻辑:启动时先尝试完全离线加载本地缓存,本地不存在则自动联网下载;同时统一两个服务的 HF 镜像配置。 +todos: + - id: matting-two-level-loading + content: 改造抠图服务 main.py(镜像、日志、绝对路径 cache_dir、两级加载)并同步 requirements.txt 注释 + status: completed + - id: classification-two-level-loading + content: 改造分类服务 main.py(镜像统一 hf-mirror.com、模型与处理器两级加载) + status: completed + - id: syntax-verify + content: 对两个 main.py 执行 py_compile 语法验证并清理 __pycache__ + status: completed + dependencies: + - matting-two-level-loading + - classification-two-level-loading +--- + +## 产品概述 + +对两个纯 Python AI 服务(衣物分类服务 Fashion-CLIP、抠图服务 BiRefNet)的模型加载逻辑统一改造:启动时先检查本地缓存,本地已存在则完全离线加载(零网络请求);本地不存在或不完整时自动联网下载模型到本地缓存,下载完成后后续启动均走离线分支。 + +## 核心功能 + +- **抠图服务改造**:新增国内镜像与日志模块,模型缓存目录改为绝对路径(消除启动工作目录依赖),模型加载改为「本地存在则离线、不存在则自动下载」两级模式,并同步更新 requirements.txt 注释说明 +- **分类服务改造**:镜像地址统一为 hf-mirror.com(与代码注释保持一致),模型与处理器加载改为同一套「本地存在则离线、不存在则自动下载」两级模式 +- **运行行为**:首次启动无缓存时自动下载(走国内镜像加速),有缓存后完全离线;日志明确区分「命中本地缓存」与「自动下载」两种路径 + +## 技术栈 + +- Python 3 + HuggingFace Transformers(CLIPModel / CLIPProcessor / AutoModelForImageSegmentation) +- 国内镜像 hf-mirror.com(通过 HF_ENDPOINT 环境变量,在 import transformers 之前设置) +- 依赖与 FastAPI 服务结构不变,仅改模型加载段 + +## 实现方案 + +### 统一两级加载模式 + +利用 `local_files_only=True` 的行为特性:本地缓存缺失或不完整时 huggingface_hub 抛出 `OSError`(Python 3 中 `LocalEntryNotFoundError` 是 `OSError` 子类)或 `ValueError`,捕获后回退联网下载。两级使用同一个 cache_dir,HF 缓存结构固定(`models--repo/snapshots/...`),无需手动判断目录结构: + +```python +try: + model = XXX.from_pretrained(REPO, ..., local_files_only=True, cache_dir=CACHE_DIR) + logger.info("模型命中本地缓存,离线加载") +except (OSError, ValueError) as e: + logger.info(f"本地未找到模型缓存,自动下载 ... ({e})") + model = XXX.from_pretrained(REPO, ..., cache_dir=CACHE_DIR) +``` + +### 抠图服务 `thirdparty/matting_server/main.py` + +1. 顶部(import transformers 之前)加镜像设置:`if "HF_ENDPOINT" not in os.environ: os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"`,并补 `import os` +2. 新增 `logging.basicConfig(level=logging.INFO)` + `logger = logging.getLogger(__name__)` +3. 模型缓存目录改为绝对路径:`_model_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "model")`,替换原相对路径 `cache_dir="./model"` +4. 模型加载改为两级加载(保持 `trust_remote_code=True`;`model.eval()`、device 判定、CUDA `model.half()` 逻辑保持不变) +5. 同目录 `requirements.txt` 注释同步:模型首次启动自动下载到本目录 `./model/` 缓存,无需手动放置 + +### 分类服务 `thirdparty/classification_server/main.py` + +1. 第 21 行镜像从 `https://hf-api.gitee.com` 改为 `https://hf-mirror.com`(与第 12 行 docstring 一致) +2. 第 450-451 行 model 与 processor 放入同一 try 块两级加载:`local_files_only=True` 先试,任一失败则整体回退联网下载;`model = model.to(device)`、`model.eval()`、CUDA `half()` 保持原位置 + +### 性能与可靠性 + +- 有缓存时零网络请求,启动时间不受网络影响;无缓存时仅首次下载(约 400MB,走镜像) +- 缓存不完整(下载中断)时第一级失败自动触发补齐,天然容错 +- 日志两条路径可观测,便于运维确认模型来源 + +## 目录结构 + +``` +thirdparty/ +├── classification_server/ +│ ├── main.py # [MODIFY] 镜像统一 hf-mirror.com;模型/处理器两级加载 +│ └── requirements.txt # 不变 +└── matting_server/ + ├── main.py # [MODIFY] 新增镜像/日志;cache_dir 绝对路径;模型两级加载 + └── requirements.txt # [MODIFY] 注释更新为自动下载说明 +``` + +## 验证方式 + +- 两个文件 `python3 -m py_compile` 语法验证,验证后清理 `__pycache__` +- 交付说明:首次启动自动下载(走 hf-mirror 镜像),有缓存后完全离线;日志区分「命中本地缓存」与「自动下载」 \ No newline at end of file diff --git a/.codebuddy/plans/mysqlmodel_类型统一与_tag_精简_19fbaf95.md b/.codebuddy/plans/mysqlmodel_类型统一与_tag_精简_19fbaf95.md new file mode 100644 index 0000000..c952d74 --- /dev/null +++ b/.codebuddy/plans/mysqlmodel_类型统一与_tag_精简_19fbaf95.md @@ -0,0 +1,108 @@ +--- +name: mysqlmodel 类型统一与 tag 精简 +overview: 将 internal/mysqlmodel 下所有模型统一改造:外键/计数/金额/时间字段类型统一为 int64,删除所有 json tag 与 DDL 类 gorm tag,只保留 column 列名映射,并把 OutfitItem、UserOrder 的标准 gorm.Model 替换为自定义 Model。 +todos: + - id: refactor-model-user + content: 改造 model.go 与 user.go,统一 int64 类型与极简 column tag + status: completed + - id: refactor-clothes-closet + content: 改造 clothes.go 与 closet.go,外键 UserID/ShareID/ClothesID 统一 int64 + status: completed + - id: refactor-outfit-order + content: 改造 outfit.go 与 order.go,替换 gorm.Model 并统一金额/时间/外键类型 + status: completed + - id: refactor-org-goods-ai-horoscope + content: 改造 org.go、goods.go、airecommend.go、horoscope.go 的类型与 tag + status: completed + - id: build-lint-verify + content: 运行 go build 与 read_lints 校验编译与 tag 语法 + status: completed + dependencies: + - refactor-model-user + - refactor-clothes-closet + - refactor-outfit-order + - refactor-org-goods-ai-horoscope +--- + +## 产品概述 + +对 `internal/mysqlmodel/` 下全部数据库模型进行一次统一规范改造,解决字段类型不一致与 tag 冗余问题,使模型层与「手动建表 + DTO 对外」的架构约定保持一致。 + +## 核心功能 + +- 将 `Model.ID`(int64)的外键引用字段统一为 int64,消除当前 uint/string 混用问题 +- 将所有 `float64` 金额字段改为 int64(单位:分),避免浮点精度误差 +- 将所有 `time.Time` 时间字段改为 int64 Unix 秒时间戳 +- 将所有 `uint`/`uint8`/`int` 计数、配额、token 数量字段统一为 int64 +- 删除所有 `json` tag(model 不直接对外,由 DTO 层承担 JSON 契约) +- 删除所有 DDL 类 gorm tag(`type`、`comment`、`uniqueIndex`、`index`),仅保留 `column` 列名映射与 `primaryKey` +- 将 `OutfitItem`、`UserOrder` 中误用的标准 `gorm.Model` 替换为项目自定义 `Model` + +## 技术栈 + +- 语言:Go +- ORM:GORM v2 +- 软删除:`gorm.io/plugin/soft_delete` +- 改动范围:仅 `internal/mysqlmodel/` 下的 10 个文件(纯模型层,无调用方依赖) + +## 实现方案 + +本次为纯数据模型类型与 tag 规范化改造,不引入新架构。已核实 `internal/dao`、`internal/service`、`internal/dto`、`internal/routers/api/v1` 均为空目录,`routers/router.go` 仅含空 gin 引擎,全项目无任何代码引用这些模型字段,因此改动无编译破坏风险。 + +### 类型映射规则 + +| 字段类别 | 原类型 | 目标类型 | 说明 | +| --- | --- | --- | --- | +| 外键/引用 ID | uint / string | int64 | UserID、TargetID、SenderID、RelatedID、ClothesID、GoodsID、ShareID、SavedOutfitID、OutfitID | +| 金额 | float64 | int64 | 单位统一为「分」,前端展示时除以 100 | +| 时间 | time.Time | int64 | Unix 秒时间戳,与 Model.CreatedAt/UpdatedAt 一致 | +| 日期统计 | time.Time | int64 | StatDate 存 `YYYYMMDD` 整数(如 20260829),避免时区偏移 | +| 计数/配额/token | uint / uint8 / int | int64 | UploadCount、MemberDays、PromptTokens、ViewCount、LikeCount 等 | +| 枚举/状态/评分 | int / int8 | 保持不变 | Sex、Status、MsgType、IsRead、CategoryMain、Scene、OrderStatus、评分等 | + + +### tag 精简规则 + +- 保留:`gorm:"column:xxx"`(列名映射 + 代码可读)、`gorm:"column:id;primaryKey"`(主键) +- 删除:所有 `json:"..."`、`type`、`comment`、`uniqueIndex`、`index` +- 字段注释保留在 Go 行尾注释中 +- `CreatedAt/UpdatedAt` 字段名命中 GORM 约定,无需 `autoCreateTime/autoUpdateTime`,自动填充秒级时间戳 + +### 关键代码结构 + +`Model` 基类改造后的最终形态: + +``` +type Model struct { + ID int64 `gorm:"column:id;primaryKey"` // 主键ID + CreatedAt int64 `gorm:"column:created_at"` // 创建时间 + UpdatedAt int64 `gorm:"column:updated_at"` // 更新时间 + DeletedAt soft_delete.DeletedAt `gorm:"column:deleted_at"` // 删除时间 + CreatedBy int64 `gorm:"column:created_by"` // 创建人 + UpdatedBy int64 `gorm:"column:updated_by"` // 更新人 + DeletedBy int64 `gorm:"column:deleted_by"` // 删除人 +} +``` + +## 目录结构 + +``` +internal/mysqlmodel/ +├── model.go # [MODIFY] Model 基类:删 json tag,保留 column + primaryKey;NewMysqlEngine 函数不动 +├── user.go # [MODIFY] User 删 json 补 column、配额 int→int64;UserDailyUsage 与 UserNotification 的 uint/time.Time→int64 +├── clothes.go # [MODIFY] Clothes:UserID string→int64(关键:UUID 改内部主键 ID),删 json 补 column +├── closet.go # [MODIFY] ClosetShare:UserID uint→int64、ViewCount/LikeCount int→int64;ClosetShareItem:ShareID string→int64、ClothesID uint→int64 +├── outfit.go # [MODIFY] Outfit:UserID uint→int64;OutfitItem:gorm.Model→自定义 Model、ClothesID uint→int64 +├── order.go # [MODIFY] UserOrder:gorm.Model→自定义 Model;UserID/GoodsID/MemberDays→int64;5 个金额 float64→int64(分);3 个时间 time.Time→int64 +├── org.go # [MODIFY] OrgLike/OrgCollect:TargetID、UserID uint→int64 +├── goods.go # [MODIFY] Goods:Price/DiscountPrice float64→int64(分);MemberDays/UploadQuota uint→int64 +├── airecommend.go # [MODIFY] AIRecommendLog:UserID/ClothesCount/PromptTokens/CompletionTokens/SavedOutfitID/ConsumeQuota→int64;RequestTime/ResponseTime→int64 +└── horoscope.go # [MODIFY] Horoscope:StatDate/GeneratedAt time.Time→int64(StatDate 存 YYYYMMDD) +``` + +## 实现注意事项 + +- 金额改「分」后,后续 service/dao 层所有金额计算必须以分为单位,注释中标注清楚 +- StatDate 存 `YYYYMMDD` 整数并在注释说明,避免跨时区日期偏移 +- redismodel 的 key 常量本次不改,其 `%d` 占位符对 int64 的 user_id 依然适用 +- 改造后运行 `go build ./...` 与 `read_lints` 确认无语法错误、tag 格式合法 \ No newline at end of file diff --git a/.codebuddy/plans/redismodel_key_设计对齐_mysqlmodel_da53c011(未完成).md b/.codebuddy/plans/redismodel_key_设计对齐_mysqlmodel_da53c011(未完成).md new file mode 100644 index 0000000..499b7f4 --- /dev/null +++ b/.codebuddy/plans/redismodel_key_设计对齐_mysqlmodel_da53c011(未完成).md @@ -0,0 +1,129 @@ +--- +name: redismodel key 设计对齐 mysqlmodel +overview: 以 internal/mysqlmodel 的实体与字段术语为基准,修复 internal/redismodel 的 Redis key 常量命名、文件组织与 key 字符串,使其语义对齐,并做最小引用同步以保持编译通过。 +todos: + - id: refactor-key-basics + content: 新建 keys.go 抽取 RedisPrefix,删除 org.go 并拆分为 like.go 与 collect.go + status: pending + - id: align-quota-keys + content: 修改 quota.go 对齐 mysqlmodel 术语,并补充 lock.go 注释 + status: pending + dependencies: + - refactor-key-basics + - id: align-auth-user-keys + content: 修改 auth.go 的 token key 为复数 Set 语义,新增 user.go 定义 KeyUserInfo + status: pending + dependencies: + - refactor-key-basics + - id: sync-redisdao-refs + content: 同步 redisdao 中 quota.go 与 user.go 的常量引用 + status: pending + dependencies: + - align-quota-keys + - align-auth-user-keys + - id: build-lint-verify + content: 运行 go build 与 read_lints 校验编译与引用完整性 + status: pending + dependencies: + - sync-redisdao-refs +--- + +## 产品概述 + +以 `internal/mysqlmodel` 的实体与字段术语为唯一基准,重构 `internal/redismodel` 下的 Redis key 常量设计,使 Redis 命名空间与数据库模型语义保持一致。本次仅修改 key 常量定义,并做 `internal/redisdao` 中常量引用的最小同步,不改动任何业务逻辑实现。 + +## 核心功能 + +- 将全局前缀 `RedisPrefix` 从 `auth.go` 抽取到独立文件 `keys.go` +- 删除命名不符的 `org.go`,按 mysqlmodel 的 `like.go`/`collect.go` 拆分为两个文件 +- 对齐 quota key 术语:`community` 改为 `outfit_share`(社区分享/搭配分享)、`wardrobe_share` 改为 `closet_share`(衣柜分享)、`upload` 改为 `cos`(cos 上传额度) +- 将 `KeyAuthUserToken` 改为复数 Set 语义 `KeyAuthUserTokens`,支持多端登录一对多 token +- 新增 `KeyUserInfo` 常量,把 `redisdao/user.go` 中绕开统一管理的 `user:info:` 前缀纳入 redismodel 并补上 `ztcloset:` 命名空间 +- 同步 `redisdao` 中对被重命名常量的引用,保证编译通过 + +## 技术栈 + +- 语言:Go +- 改动核心:`internal/redismodel` 下的 Redis key 常量定义 +- 语义基准:`internal/mysqlmodel`(`user.go`、`quota.go`、`like.go`、`collect.go`、`order.go`、`goods.go`、`quotaflowlog.go`) +- 最小同步:`internal/redisdao`(仅更新常量引用,不改函数逻辑与函数名) + +## 实现方案 + +### 总体策略 + +以 mysqlmodel 的实体命名和字段注释为准,重命名 Redis key 常量和 key 字符串,使两层模型术语一致。重命名导致 key 字符串变化,用户已确认适用于未上线/可清库场景。由于 `redisdao` 直接引用了这些常量,必须同步更新引用,否则无法编译。 + +### 关键决策 + +1. **`RedisPrefix` 独立成文件**:从 `auth.go` 移至新建的 `keys.go`,作为全包共享的命名空间前缀,消除其他文件对 `auth.go` 的隐式依赖。 +2. **`org.go` 拆分为 `like.go` 与 `collect.go`**:对齐 mysqlmodel 的文件划分,每个文件只包含对应业务的 key 常量,并补充 `like_type:target_id`、`collect_type:target_id` 的参数注释。 +3. **quota 术语统一**:以 `UserDailyUsage` 字段和 `Quota.QuotaType` 注释为准,AI 保持不变,`community` 统一为 `outfit_share`,`wardrobe_share` 统一为 `closet_share`,`upload` 统一为 `cos`。 +4. **token 一对多语义修正**:`KeyAuthUserToken` 改为 `KeyAuthUserTokens`,key 使用复数 `auth:user:tokens:%d`,注释明确为 `user_id -> token 集合(Set)`,支持多端登录。该常量当前无引用,改名不产生编译影响。 +5. **`user:info` 纳入统一管理**:新增 `redismodel/user.go` 定义 `KeyUserInfo`(带 `ztcloset:` 前缀),`redisdao/user.go` 移除本地 `userInfoKeyPrefix` 常量并改用该常量。 +6. **锁定 key 保留并补注释**:`lock.go` 的三个常量与 mysqlmodel 的 `Order`、AIRecommend 业务对应,保留不动,仅补充参数与用途注释。 +7. **不改 redisdao 函数名**:`QuotaDailyIncrCommunity`、`QuotaDailyIncrShare` 等函数名本次不调整,仅同步函数体内引用的常量名,避免扩大影响面。 + +## 目录结构 + +``` +internal/redismodel/ +├── keys.go # [NEW] 定义全局 RedisPrefix = "ztcloset:" +├── auth.go # [MODIFY] 移除 RedisPrefix;KeyAuthUserToken 改为 KeyAuthUserTokens(Set 语义) +├── quota.go # [MODIFY] community→outfit_share、wardrobe_share→closet_share、upload→cos +├── like.go # [NEW] KeyLikeSet(由 org.go 拆分) +├── collect.go # [NEW] KeyCollectSet(由 org.go 拆分) +├── org.go # [DELETE] 内容已拆分至 like.go/collect.go +├── lock.go # [MODIFY] 补充参数与用途注释(常量不变) +└── user.go # [NEW] KeyUserInfo 缓存 key 常量 + +internal/redisdao/ +├── quota.go # [MODIFY] 同步引用 KeyQuotaDailyOutfitShare / KeyQuotaDailyClosetShare +└── user.go # [MODIFY] 移除本地 userInfoKeyPrefix,改用 redismodel.KeyUserInfo +``` + +## 关键代码结构 + +修复后的 `internal/redismodel` key 常量全集: + +``` +// keys.go +const RedisPrefix = "ztcloset:" + +// auth.go +const ( + KeyAuthAccess = RedisPrefix + "auth:access:%s" // access_token -> user_id + KeyAuthRefresh = RedisPrefix + "auth:refresh:%s" // refresh_token -> user_id + KeyAuthUserTokens = RedisPrefix + "auth:user:tokens:%d" // user_id -> token 集合(Set,支持多端登录) +) + +// quota.go +const ( + KeyQuotaDailyAI = RedisPrefix + "quota:daily:ai:%d:%s" // user_id:date,当日AI推荐次数 + KeyQuotaDailyOutfitShare = RedisPrefix + "quota:daily:outfit_share:%d:%s" // user_id:date,当日社区分享次数(搭配分享) + KeyQuotaDailyClosetShare = RedisPrefix + "quota:daily:closet_share:%d:%s" // user_id:date,当日衣柜分享次数 + KeyQuotaCosTotal = RedisPrefix + "quota:cos:total:%d" // user_id,cos 上传总配额 +) + +// like.go +const KeyLikeSet = RedisPrefix + "like:set:%d:%d" // like_type:target_id + +// collect.go +const KeyCollectSet = RedisPrefix + "collect:set:%d:%d" // collect_type:target_id + +// lock.go +const ( + KeyLockOrderCreate = RedisPrefix + "lock:order:create:%d" // order_id,下单防重 + KeyLockPayCallback = RedisPrefix + "lock:pay:callback:%s" // order_no,支付回调幂等 + KeyLockAIRecommend = RedisPrefix + "lock:ai:recommend:%d" // user_id,AI推荐防并发 +) + +// user.go +const KeyUserInfo = RedisPrefix + "user:info:%d" // user_id,缓存用户鉴权核心信息 +``` + +## 实现注意事项 + +- 修改后需运行 `go build ./...` 与 `read_lints` 校验,重点确认 `redisdao` 内无遗漏的旧常量引用。 +- 严格限定在 key 常量层:不改 `redisdao` 的函数名与业务逻辑,不改 `middleware/lzcwyjwt.go` 的 status 判断,不改 quota 过期/时区实现。 +- key 字符串变更属于破坏性变更,依赖可清库/未上线前提(用户已确认)。 \ No newline at end of file diff --git a/.codebuddy/plans/redismodel_从0全量设计_31a88700.md b/.codebuddy/plans/redismodel_从0全量设计_31a88700.md new file mode 100644 index 0000000..db9231c --- /dev/null +++ b/.codebuddy/plans/redismodel_从0全量设计_31a88700.md @@ -0,0 +1,154 @@ +--- +name: redismodel 从0全量设计 +overview: 以 internal/mysqlmodel 全部实体语义为基准,从 0 重新设计 internal/redismodel 的 Redis key 常量层,覆盖认证、用户、点赞、收藏、配额、锁、星座、通知、分享等全部业务,文件组织与术语对齐 mysqlmodel。 +todos: + - id: create-keys-basics + content: 新建 keys.go 定义 RedisPrefix,删除旧 org.go + status: completed + - id: rewrite-auth-user + content: 重写 auth.go(token 复数 Set 语义)并新建 user.go 定义 KeyUserInfo + status: completed + dependencies: + - create-keys-basics + - id: rewrite-quota-like-collect + content: 重写 quota.go(术语对齐与总配额参数化)并新建 like.go、collect.go + status: completed + dependencies: + - create-keys-basics + - id: create-cache-count-keys + content: 新建 closet.go、outfit.go、horoscope.go、notification.go 的缓存与计数 key + status: completed + dependencies: + - create-keys-basics + - id: rewrite-lock-keys + content: 重写 lock.go 补齐全量锁与幂等 key 及参数注释 + status: completed + dependencies: + - create-keys-basics + - id: build-lint-verify + content: 运行 go build 与 read_lints 校验 internal/redismodel 包 + status: completed + dependencies: + - rewrite-auth-user + - rewrite-quota-like-collect + - create-cache-count-keys + - rewrite-lock-keys +--- + +## 产品概述 + +以 `internal/mysqlmodel` 的实体与字段术语为唯一语义基准,从 0 重新设计 `internal/redismodel` 的 Redis key 常量层。本次只交付 key 常量定义,不写任何 `redisdao` 操作函数(`redisdao` 已清空,由用户后续补充),也不改动 `middleware`。 + +## 核心功能 + +- 全局命名空间前缀 `RedisPrefix = "ztcloset:"` 独立成文件,不再挂靠在 auth.go +- 认证与会话:access/refresh token 映射,以及 `user_id -> token 集合` 的 Set 反向索引(支持多端登录) +- 用户缓存:`user_id -> 用户鉴权核心信息`(对齐 `User.Status` 语义) +- 点赞/收藏:按 `type:target_id` 分桶的 Set key,成员为 user_id +- 配额:每日计数 key(对齐 `UserDailyUsage` 的 AI/社区分享/衣柜分享三字段)与总配额 key(对齐 `Quota.QuotaType` 四类枚举) +- 缓存与计数:星座运势缓存、通知未读数、衣柜分享访问计数、公开穿搭浏览计数 +- 分布式锁与幂等:下单防重、支付回调幂等、退款回调幂等、AI 推荐防并发、AI 星座防并发、衣柜/穿搭审核防重 +- 文件组织对齐 mysqlmodel 的模块划分(like/collect 分开,删除旧的 org.go) + +## 技术栈 + +- 语言:Go +- 改动范围:仅 `internal/redismodel` 下的 key 常量定义 +- Redis 客户端(已存在,不修改):`global.RedisEngine`(`github.com/redis/go-redis/v9`) +- 语义基准:`internal/mysqlmodel` 全部实体 + +## 实现方案 + +### 总体策略 + +推翻旧的 4 个文件(`auth.go`、`quota.go`、`org.go`、`lock.go`),按 mysqlmodel 的实体划分重新组织为 11 个文件。每个文件只包含对应业务模块的 key 常量,常量名与 key 字符串均对齐 mysqlmodel 的字段/枚举术语。所有 key 统一携带 `ztcloset:` 前缀。 + +### 关键设计决策 + +1. **命名空间收敛**:`RedisPrefix` 抽到独立 `keys.go`,作为全包共享前缀,消除跨文件隐式依赖。 +2. **文件对齐 mysqlmodel**:删除语义不符的 `org.go`,点赞、收藏分别用 `like.go`、`collect.go`;新增 `user.go`、`closet.go`、`outfit.go`、`horoscope.go`、`notification.go` 对应各实体。 +3. **术语统一**:`community → outfit_share`(社区分享/搭配分享,对齐 `UserDailyUsage.OutfitShareCount`)、`wardrobe_share → closet_share`(对齐 `CLosetShareCount`)、`upload → cos`(对齐 `Quota.QuotaType=1=cos`)。 +4. **token 一对多**:`KeyAuthUserToken` 改为复数 `KeyAuthUserTokens`,key 用 `auth:user:tokens:%d` 并明确为 Set 语义,解决单值无法表达多端登录的问题。 +5. **配额双维度**:每日计数按 `UserDailyUsage` 的三个具名字段拆分;总配额用 `quota:total:%d:%d`(`user_id:quota_type`)参数化覆盖 `Quota.QuotaType` 全部 4 类,与数据库表结构(user_id + quota_type 唯一)完全对齐。 +6. **锁与幂等维度**:下单用 order_id、支付回调用 order_no、退款回调用 refund_no、AI 推荐/星座用 user_id 维度防并发、审核用 target_id 防重,均对齐 mysqlmodel 对应字段。 +7. **纯流水不设计**:`QuotaFlowLog`、`VIPFlowLog` 为纯流水日志;`Goods`、`Clothes`、`ClosetShareItem`、`OutfitItem` 为低频/关系数据,均无需 Redis key,避免过度设计。 + +## 目录结构 + +``` +internal/redismodel/ +├── keys.go # [NEW] 全局 RedisPrefix = "ztcloset:" +├── auth.go # [MODIFY] 移除 RedisPrefix;KeyAuthUserToken 改为复数 Set 语义 +├── user.go # [NEW] KeyUserInfo 用户鉴权缓存 key +├── like.go # [NEW] KeyLikeSet(由旧 org.go 拆分) +├── collect.go # [NEW] KeyCollectSet(由旧 org.go 拆分) +├── quota.go # [MODIFY] 每日计数对齐 UserDailyUsage;总配额参数化对齐 QuotaType +├── closet.go # [NEW] KeyClosetShareView 衣柜分享访问计数 +├── outfit.go # [NEW] KeyOutfitView 公开穿搭浏览计数 +├── horoscope.go # [NEW] KeyHoroscope 星座运势缓存 +├── notification.go # [NEW] KeyNotificationUnread 通知未读数 +├── lock.go # [MODIFY] 补齐全量锁/幂等 key 与参数注释 +└── org.go # [DELETE] 内容已拆分至 like.go / collect.go +``` + +## 关键代码结构 + +修复后的 key 常量全集(均为常量定义,无函数): + +``` +// keys.go +const RedisPrefix = "ztcloset:" + +// auth.go +const ( + KeyAuthAccess = RedisPrefix + "auth:access:%s" // access_token -> user_id(String) + KeyAuthRefresh = RedisPrefix + "auth:refresh:%s" // refresh_token -> user_id(String) + KeyAuthUserTokens = RedisPrefix + "auth:user:tokens:%d" // user_id -> token 集合(Set,支持多端登录) +) + +// user.go +const KeyUserInfo = RedisPrefix + "user:info:%d" // user_id -> 用户鉴权核心信息(String/JSON,含 Status) + +// like.go +const KeyLikeSet = RedisPrefix + "like:set:%d:%d" // like_type:target_id,成员为 user_id(Set) + +// collect.go +const KeyCollectSet = RedisPrefix + "collect:set:%d:%d" // collect_type:target_id,成员为 user_id(Set) + +// quota.go +const ( + KeyQuotaDailyAI = RedisPrefix + "quota:daily:ai:%d:%s" // user_id:date,当日AI推荐次数 + KeyQuotaDailyOutfitShare = RedisPrefix + "quota:daily:outfit_share:%d:%s" // user_id:date,当日社区分享次数(搭配分享) + KeyQuotaDailyClosetShare = RedisPrefix + "quota:daily:closet_share:%d:%s" // user_id:date,当日衣柜分享次数 + KeyQuotaTotal = RedisPrefix + "quota:total:%d:%d" // user_id:quota_type,总配额(1=cos 2=AI 3=社区分享 4=衣柜分享) +) + +// closet.go +const KeyClosetShareView = RedisPrefix + "closet:share:view:%s" // share_uuid,衣柜分享访问计数(INCR) + +// outfit.go +const KeyOutfitView = RedisPrefix + "outfit:view:%d" // outfit_id,公开穿搭浏览计数(INCR) + +// horoscope.go +const KeyHoroscope = RedisPrefix + "horoscope:%s:%d" // zodiac:stat_date(YYYYMMDD),星座运势缓存(String/JSON) + +// notification.go +const KeyNotificationUnread = RedisPrefix + "notification:unread:%d" // user_id,未读消息数(INCR/DECR) + +// lock.go +const ( + KeyLockOrderCreate = RedisPrefix + "lock:order:create:%d" // order_id,下单防重 + KeyLockPayCallback = RedisPrefix + "lock:pay:callback:%s" // order_no,支付回调幂等 + KeyLockRefundCallback = RedisPrefix + "lock:refund:callback:%s" // refund_no,退款回调幂等 + KeyLockAIRecommend = RedisPrefix + "lock:ai:recommend:%d" // user_id,AI推荐防并发 + KeyLockAIHoroscope = RedisPrefix + "lock:ai:horoscope:%d:%s" // user_id:zodiac,AI星座防并发 + KeyLockClosetAudit = RedisPrefix + "lock:audit:closet:%d" // target_id,衣柜分享审核防重 + KeyLockOutfitAudit = RedisPrefix + "lock:audit:outfit:%d" // target_id,穿搭审核防重 +) +``` + +## 实现注意事项 + +- 校验仅针对 `redismodel` 包:`go build ./internal/redismodel/` 与 `read_lints`(路径限定在 `internal/redismodel`)。不要运行 `go build ./...`,因为 `redisdao` 已清空而 `middleware/lzcwyjwt.go` 仍引用 `redisdao.UserInfoGet`,全量编译会失败(该问题由用户后续补 redisdao 解决,不在本次范围)。 +- key 字符串变更属于破坏性变更,依赖可清库/未上线前提(用户已确认)。 +- 所有注释需标明占位符含义与数据结构类型(String/Set/INCR/JSON),为后续 redisdao 实现提供契约。 \ No newline at end of file diff --git a/.codebuddy/plans/service-span-and-async-trace_d93c7ebf.md b/.codebuddy/plans/service-span-and-async-trace_d93c7ebf.md new file mode 100644 index 0000000..13cc41c --- /dev/null +++ b/.codebuddy/plans/service-span-and-async-trace_d93c7ebf.md @@ -0,0 +1,100 @@ +--- +name: service-span-and-async-trace +overview: 给 service 层所有对外入口方法补埋 span(svc.Xxx),并修复 clothes 上传异步 goroutine 的 trace 断链,使调用链可读、异步链路可关联。 +todos: + - id: clothes-spans + content: 为 clothes.go 的 6 个入口方法(Upload、ProcessStatusGetByUUID、GetByUUID、UpdateByUUID、DeleteByUUID、List)补埋 svc span + status: completed + - id: clothes-async-trace + content: 修复 clothes.go 异步断链:改造 processImageAsync 签名与 ctx 派生、补 svc.ProcessImageAsync span、更新调用点、新增 otel/trace import + status: completed + dependencies: + - clothes-spans + - id: outfit-closet-collect-like-spans + content: 为 outfit.go、closet.go、collect.go、like.go 的入口方法补埋 svc span 并新增 global import + status: completed + - id: notification-quota-user-goods-horoscope-spans + content: 为 notification.go、quota.go、user.go、goods.go、horoscope.go 的入口方法补埋 svc span 并补充所需 import + status: completed + - id: order-airecommend-spans + content: 为 order.go、airecommend.go 的入口方法补埋 svc span 并新增 global import + status: completed + - id: build-vet-verify + content: 运行 go build ./... 与 go vet ./... 验证编译与静态检查通过 + status: completed + dependencies: + - clothes-spans + - clothes-async-trace + - outfit-closet-collect-like-spans + - notification-quota-user-goods-horoscope-spans + - order-airecommend-spans +--- + +## 用户需求 + +1. 在 service 层对外入口方法补埋 OpenTelemetry span,使 trace 调用链可读。 +2. 修复衣物上传异步 goroutine 的 trace 断链,使抠图、COS 上传、分类、回填等异步 DAO span 能关联回原请求。 + +## 功能内容 + +- 为 12 个 service 文件、共 41 个对外入口方法(首字母大写的导出方法)统一补埋 `svc.Xxx` span,命名与现有 `dao.Xxx`、`upload.Xxx` 前缀保持一致。 +- 私有辅助方法不埋点,避免产生过深的 span 森林。 +- 修复 `processImageAsync` 使用 `context.Background()` 导致异步链路丢失父 trace 的问题,通过 `trace.ContextWithSpanContext` 继承 trace 信息、同时切断取消信号。 +- 异步分支补一个 `svc.ProcessImageAsync` span,将异步处理在 trace 中聚合成一个独立分组。 + +## 视觉/可观测效果 + +- 在 trace 平台中,每个 HTTP 请求下能看到 `svc.Xxx` 容器 span,其下嵌套对应业务调用的 `dao.Xxx`、`upload.Xxx` span,调用层次清晰。 +- 衣物上传后,后台异步处理(抠图、COS、分类、回填)不再生成孤立 trace,而是挂在原请求(或 `svc.ProcessImageAsync`)之下,便于端到端排查。 + +## Tech Stack + +- 语言:Go 1.27 +- 可观测:OpenTelemetry Trace(已接入,`global.Tracer`) +- Web 框架:Gin(API 层已由 `otelgin.Middleware` 自动埋 server span,本次不涉及) + +## Implementation Approach + +采用「只补边界、不埋全程」的埋点策略: + +1. service 入口方法方法体开头统一插入: + +``` +ctx, span := global.Tracer.Start(ctx, "svc.Xxx") +defer span.End() +``` + +span 名 = `svc.` + 方法名。通过覆盖入口方法,使 DAO/upload 子 span 在 trace 中正确归属到业务动作。 + +2. 修复异步断链:将 `processImageAsync` 签名改为接收 `parentCtx context.Context`,内部用: + +``` +ctx, cancel := context.WithTimeout(trace.ContextWithSpanContext(context.Background(), trace.SpanContextFromContext(parentCtx)), 3*time.Minute) +``` + +继承父 trace 的 span context,但用 `context.Background()` 切断请求 ctx 的取消信号(保证请求结束后 goroutine 仍能运行),再补 `svc.ProcessImageAsync` span 聚合异步分支。 + +## Implementation Notes + +- service 层 span 只做 start/end,不调用 `span.RecordError`:service 方法返回 `*errcode.Error` 且在多个 return 点返回,若记录错误需改成具名返回值,侵入过大;本次目标为调用链可读,span 嵌套与耗时已满足需求。 +- 需新增 `global` import 的文件:order.go、goods.go、outfit.go、horoscope.go、collect.go、closet.go、like.go、notification.go;airecommend.go、clothes.go、user.go、quota.go 已导入 `global`。 +- clothes.go 需新增 `go.opentelemetry.io/otel/trace` import(go.mod 已包含该依赖)。 +- 保持与现有 DAO 埋点风格一致:span 名用 `dao.Xxx`/`upload.Xxx` 相同的大写方法名风格,不引入新的埋点封装或 helper,避免与现有约定不一致。 + +## Directory Structure + +``` +internal/service/ +├── clothes.go # [MODIFY] 6 个入口方法补 span;processImageAsync 签名改造、trace 传播修复、新增 svc.ProcessImageAsync span、更新调用点、新增 otel/trace import +├── outfit.go # [MODIFY] Create/GetByUUID/UpdateByUUID/List/Delete/Public 补 span;新增 global import +├── closet.go # [MODIFY] Create/Detail/List/Cancel 补 span;新增 global import +├── collect.go # [MODIFY] Collect/List 补 span;新增 global import +├── like.go # [MODIFY] LikeCreate/GetByTargetID 补 span;新增 global import +├── notification.go # [MODIFY] List/GetByUUID/Read/Unread 补 span;新增 global import +├── quota.go # [MODIFY] InfoGetBySelf/FlowList 补 span(global 已导入) +├── user.go # [MODIFY] WxLogin/InfoGetBySelf/ProfileUpdate/RefreshToken 补 span(global 已导入) +├── goods.go # [MODIFY] GetByCode/List 补 span;新增 global import +├── horoscope.go # [MODIFY] GetToday 补 span;新增 global import +├── order.go # [MODIFY] Create/PayNotify/Cancel/List/RefundApply/RefundNotify 补 span;新增 global import +└── airecommend.go # [MODIFY] Create/Save 补 span(global 已导入) +``` \ No newline at end of file diff --git a/.codebuddy/plans/service-span-naming-dedupe_b2900987.md b/.codebuddy/plans/service-span-naming-dedupe_b2900987.md new file mode 100644 index 0000000..1c598a8 --- /dev/null +++ b/.codebuddy/plans/service-span-naming-dedupe_b2900987.md @@ -0,0 +1,93 @@ +--- +name: service-span-naming-dedupe +overview: 将 service 层 span 名从 svc.<方法名> 统一改为 svc.<领域><方法名>,消除跨 service 的同名 span,使 span 名全局唯一、可按名过滤,并与 DAO 层 dao.<领域><动作> 命名风格对齐。 +todos: + - id: rename-clothes-outfit + content: 替换 clothes.go、outfit.go 共 13 处 span 命名(Clothes/Outfit 领域前缀) + status: completed + - id: rename-closet-collect-like + content: 替换 closet.go、collect.go、like.go 共 8 处 span 命名(ClosetShare/Collect/Like 领域前缀) + status: completed + - id: rename-notification-quota-user + content: 替换 notification.go、quota.go、user.go 共 10 处 span 命名(Notification/Quota/User 领域前缀) + status: completed + - id: rename-goods-horoscope-order-airecommend + content: 替换 goods.go、horoscope.go、order.go、airecommend.go 共 11 处 span 命名(Goods/Horoscope/Order/AIRecommend 领域前缀) + status: completed + - id: verify-build-vet + content: 运行 go build ./... 与 go vet ./...,并搜索确认无裸 svc 方法名 span + status: completed + dependencies: + - rename-clothes-outfit + - rename-closet-collect-like + - rename-notification-quota-user + - rename-goods-horoscope-order-airecommend +--- + +## 用户需求 + +将 service 层已埋设的 span 命名从 `svc.<方法名>` 调整为 `svc.<领域名><方法名>`,解决因多个 service 存在同名方法而导致的 span 名重复问题(如 `svc.Create`、`svc.List`、`svc.GetByUUID`),使每个 span 名全局唯一、可精确过滤与区分业务归属。 + +## 功能内容 + +- 仅修改 `global.Tracer.Start(ctx, "svc.Xxx")` 中的 span 名字符串,不改变业务逻辑、方法签名、import 或返回类型。 +- 领域名取 Service 结构体名去掉 `Service` 后缀,与 DAO 层 `dao.ClothesCreate`、`dao.OutfitCreate` 的「领域名 + 动作」驼峰命名风格保持一致。 +- 覆盖 12 个 service 文件、共 42 处 span。 + +## 技术栈 + +- 语言:Go +- 可观测:OpenTelemetry Trace(`global.Tracer.Start`) +- 改动性质:纯字符串替换,无新增依赖、无结构变更 + +## 实现方案 + +### 命名规则 + +- 领域名 = Service 结构体名去掉 `Service` 后缀: +- ClothesService → Clothes +- OutfitService → Outfit +- ClosetShareService → ClosetShare +- CollectService → Collect +- LikeService → Like +- NotificationService → Notification +- QuotaService → Quota +- UserService → User +- GoodsService → Goods +- HoroscopeService → Horoscope +- OrderService → Order +- AIRecommendService → AIRecommend +- span 名 = `svc.` + 领域名 + 方法名,驼峰无分隔,与方法名大小写一致。 + +### 特殊处理 + +- `CollectService.Collect` → `svc.CollectCollect`、`LikeService.LikeCreate` → `svc.LikeLikeCreate`,为「领域名 + 方法名」严格拼接导致的字面重复,但规则统一、机械可推导;本次不重命名方法本身。 + +## 实现细节 + +- 仅替换 `global.Tracer.Start(ctx, "svc.Xxx")` 的第二个字符串参数。 +- 不改 import、不新增/删除 span、不调整方法体其他内容。 +- 每个文件内按方法逐一替换,保证 42 处全部覆盖。 + +## 目录结构 + +``` +internal/service/ +├── clothes.go # [MODIFY] svc.Upload→svc.ClothesUpload、svc.ProcessImageAsync→svc.ClothesProcessImageAsync、svc.ProcessStatusGetByUUID→svc.ClothesProcessStatusGetByUUID、svc.GetByUUID→svc.ClothesGetByUUID、svc.UpdateByUUID→svc.ClothesUpdateByUUID、svc.DeleteByUUID→svc.ClothesDeleteByUUID、svc.List→svc.ClothesList(7 处) +├── outfit.go # [MODIFY] svc.Create→svc.OutfitCreate、svc.GetByUUID→svc.OutfitGetByUUID、svc.UpdateByUUID→svc.OutfitUpdateByUUID、svc.List→svc.OutfitList、svc.Delete→svc.OutfitDelete、svc.Public→svc.OutfitPublic(6 处) +├── closet.go # [MODIFY] svc.Create→svc.ClosetShareCreate、svc.Detail→svc.ClosetShareDetail、svc.List→svc.ClosetShareList、svc.Cancel→svc.ClosetShareCancel(4 处) +├── collect.go # [MODIFY] svc.Collect→svc.CollectCollect、svc.List→svc.CollectList(2 处) +├── like.go # [MODIFY] svc.LikeCreate→svc.LikeLikeCreate、svc.GetByTargetID→svc.LikeGetByTargetID(2 处) +├── notification.go # [MODIFY] svc.List→svc.NotificationList、svc.GetByUUID→svc.NotificationGetByUUID、svc.Read→svc.NotificationRead、svc.Unread→svc.NotificationUnread(4 处) +├── quota.go # [MODIFY] svc.InfoGetBySelf→svc.QuotaInfoGetBySelf、svc.FlowList→svc.QuotaFlowList(2 处) +├── user.go # [MODIFY] svc.WxLogin→svc.UserWxLogin、svc.InfoGetBySelf→svc.UserInfoGetBySelf、svc.ProfileUpdate→svc.UserProfileUpdate、svc.RefreshToken→svc.UserRefreshToken(4 处) +├── goods.go # [MODIFY] svc.GetByCode→svc.GoodsGetByCode、svc.List→svc.GoodsList(2 处) +├── horoscope.go # [MODIFY] svc.GetToday→svc.HoroscopeGetToday(1 处) +├── order.go # [MODIFY] svc.Create→svc.OrderCreate、svc.PayNotify→svc.OrderPayNotify、svc.Cancel→svc.OrderCancel、svc.List→svc.OrderList、svc.RefundApply→svc.OrderRefundApply、svc.RefundNotify→svc.OrderRefundNotify(6 处) +└── airecommend.go # [MODIFY] svc.Create→svc.AIRecommendCreate、svc.Save→svc.AIRecommendSave(2 处) +``` + +## 验证方式 + +- `go build ./...` 与 `go vet ./...` 通过。 +- 全项目搜索确认不再存在 `"svc.Create"`、`"svc.List"`、`"svc.GetByUUID"` 等裸方法名 span。 \ No newline at end of file diff --git a/.codebuddy/plans/service层完整实现_3a88b729.md b/.codebuddy/plans/service层完整实现_3a88b729.md new file mode 100644 index 0000000..4765a8a --- /dev/null +++ b/.codebuddy/plans/service层完整实现_3a88b729.md @@ -0,0 +1,147 @@ +--- +name: service层完整实现 +overview: 从零实现 internal/service 全部业务逻辑(用户/衣物/穿搭/衣柜分享/收藏/点赞/通知/商品/订单退款/额度/运势/AI推荐),复用已就绪的 dao/dto/redis/pkg,并接入真实微信登录与微信支付/退款(含配置与依赖),公开内容自动审核通过,保证 service 层可独立编译。 +todos: + - id: setup-wechat-infra + content: 新增微信小程序/支付配置项与依赖,实现 pkg/wechat(code2session、JSAPI下单、回调验签解密、退款),并在 setting/global/main.go 接线 + status: completed + - id: service-foundation + content: 创建 constants.go 常量与 service.go 公共工具(错误映射、redis.Nil 判定、分页默认、consumeQuota/refundQuota 额度封装) + status: completed + - id: user-quota-horoscope + content: 实现 user/quota/horoscope 三个 service:微信登录注册+初始化额度、用户信息缓存、Token刷新、额度概览与流水、按生日算星座读取今日运势 + status: completed + dependencies: + - setup-wechat-infra + - service-foundation + - id: clothes-pipeline + content: 实现 ClothesService:上传扣额度+落COS+建记录,goroutine 异步抠图分类回填状态与失败回退,状态查询与增删改查列表 + status: completed + dependencies: + - service-foundation + - id: community-and-closet + content: 实现 outfit/like/collect/notification/closet 五个 service:穿搭增删改查与公开(自动审核通过+扣额度)、点赞收藏与作者通知、通知读写未读数、衣柜分享创建/详情/列表/取消 + status: completed + dependencies: + - clothes-pipeline + - id: order-refund + content: 实现 goods 与 OrderService:商品查询、微信JSAPI下单、支付回调发放会员/额度权益+通知、取消订单、订单列表、申请退款与退款回调(幂等+状态流转) + status: completed + dependencies: + - setup-wechat-infra + - user-quota-horoscope + - id: ai-recommend + content: 实现 AIRecommendService:防并发扣AI额度、基于衣橱与运势调用 tokenhub 生成推荐穿搭、幂等流水、保存到我的搭配,失败回退额度 + status: completed + dependencies: + - community-and-closet + - user-quota-horoscope +--- + +## 产品概述 + +为"衣柜/穿搭"微信小程序后端补齐 **service 业务逻辑层**(`internal/service`),将已就绪的 DTO、MySQL DAO、Redis DAO 与 pkg 能力(大模型推荐、抠图、分类、COS、JWT、错误码)编排为完整可用的业务服务,覆盖 `internal/dto` 中全部请求/响应。本次仅实现 service 层(保证可独立编译),不实现 handler/router。 + +## 核心功能 + +- **用户与登录**:微信小程序 code2session 真实登录、注册即初始化默认额度、资料修改、Token 刷新、用户信息缓存。 +- **衣物管理**:上传(扣额度、落 COS)+ 异步抠图/分类回填、状态查询、增删改查、列表分页。 +- **穿搭管理**:创建/编辑/删除、明细组装、公开设置(公开即自动审核通过并写审核日志、消耗社区分享额度)。 +- **衣柜分享**:部分/全量快照分享、访客详情(含过期与状态校验、访问计数)、我的分享列表、取消;创建即自动审核通过。 +- **社区互动**:点赞/收藏及其取消、点赞给作者发通知、点赞数/收藏数统计(Redis 优先)。 +- **通知**:列表、详情、标记已读、未读数(Redis 优先回落 MySQL)。 +- **商品与订单**:商品查询/列表;下单(真实微信 JSAPI 支付)、支付回调发放会员与额度权益、取消、订单列表;申请退款与退款回调(真实微信支付退款)。 +- **额度与运势**:额度概览与流水;按用户生日计算星座返回今日运势(Redis→MySQL 多级读取)。 +- **AI 穿搭推荐**:防并发扣 AI 额度、基于衣橱与星座运势调用大模型生成推荐穿搭、幂等流水、保存到"我的搭配"。 + +## 关键约束 + +- 所有"我的"资源操作必须校验归属(用户/衣物/穿搭/分享/通知/订单)。 +- 微信密钥仅来自配置,不硬编码;支付/退款回调必须验签解密并做幂等;额度扣减以数据库原子条件更新为准,Redis 作缓存加速。 + +## 技术栈 + +- 语言/框架:Go 1.27、Gin(沿用现有项目);ORM 为 GORM(含 `soft_delete` 软删除);缓存 go-redis/v9。 +- 复用能力:`internal/mysqldao`(结构体 DAO + `global.MysqlEngine`)、`internal/redisdao`(**包级函数**,未命中返回 `redis.Nil`)、`pkg/tokenhub`(大模型运势/推荐)、`pkg/matting`(抠图)、`pkg/classification`(分类)、`pkg/upload` + `pkg/cos`(落盘/上传/CDN URL)、`pkg/lzcwyjwt`(JWT)、`pkg/errcode`(业务错误码)、`pkg/app`(响应封装,供后续 handler 用)、`global.Tracer`(OTel)。 +- 新增依赖:`github.com/wechatpay-apiv3/wechatpay-go`(微信支付 v3 官方 SDK,用于 JSAPI 下单/回调验签解密/退款);微信登录 code2session 用标准库 `net/http` 直接请求固定域名 `api.weixin.qq.com`(无需额外依赖)。已具备 `github.com/google/uuid` 生成各类 UUID。 + +## 实现思路 + +采用与 DAO 一致的**结构体 Service + 构造函数**风格(`NewXxxService()` 内部持有所需 DAO 实例),按业务域分文件放置于 `internal/service`,统一方法签名约定:`func (s *XxxService) Method(ctx context.Context, userID int64, req *dto.XxxRequest) (*dto.XxxResponse, *errcode.Error)`,将底层 `error` 映射为语义化 `*errcode.Error`(DB→`DatabaseError`、缓存→`CacheError`、`redis.Nil` 视为未命中而非错误)。service 层不依赖 gin,保证 `go build ./internal/service/...` 可独立编译。 + +关键决策与权衡: + +- **枚举常量集中化**:mysqlmodel 状态/类型仅有注释、无 Go 常量。新增 `internal/service/constants.go` 定义可读常量(状态、类型、额度类型、变更类型等),消除魔法数字,降低跨域协作出错概率。 +- **额度扣减一致性**:以 MySQL `QuotaIncreaseUsed`(`used+n<=total` 条件原子更新)为**权威扣减**,保证不超发(乐观并发);Redis `QuotaRemain*`/每日计数作为读取加速与限流,二者不一致时以 MySQL 为准。封装 `consumeQuota/refundQuota` 复用于衣物上传、公开分享、AI 推荐。 +- **异步图片处理**:`ClothesUpload` 同步完成校验/扣额度/落原图/建记录后立即返回,抠图+分类放入 goroutine(派生独立 `context`、`recover` 兜底),逐步更新 `image_process_status`;任一步失败置状态 5 并写 `fail_reason` 且回退额度,避免阻塞请求、控制外部 RPC 时延影响。 +- **支付幂等与状态机**:下单/支付回调/退款回调分别用 `redisdao.TryLock`(`KeyLockOrderCreate/KeyLockPayCallback/KeyLockRefundCallback`)+ `OrderUpdateStatusByOrderNo(from→to)` 条件流转双保险,防重复回调重复发放权益。 +- **多级读取**:运势、未读数、点赞/收藏计数走 Redis 优先、未命中回落 MySQL 并回填,降低热点查询压力。 + +## 实现要点(防回归) + +- redisdao 是包级函数,未命中返回 `redis.Nil`,必须 `errors.Is(err, redis.Nil)` 判断,不可当错误抛。 +- mysqlmodel 均内嵌 `Model`,`clothes` 表主色列名为 `color`(非 `color_main`),且 dto `ClothesUpdateByUUIDRequest.ColorMain` 的 tag 为 `json:"color"`、`ClothesGetByUUIDResponse.ColorMain` 为 `json:"color_main"`——映射一律以 model 字段为准,`updates` map 的 key 用真实列名 `color`。 +- dto `GoodsGetByCodeResponse.UploadQuota` 对应 model `Goods.CosQuota`;`AIRecommendQuota` 无对应 dto 字段(发放额度时仍需处理)。注意 `UserDailyUsage.CLosetShareCount` 原始拼写。 +- 复用现有 logger(`log/slog`,已接入 OTel context handler);错误日志不打印密钥/回调原文明文,避免 PII/密钥泄露。 +- 归属校验统一在 service 层做(AuthZ);所有 SQL 走 DAO 参数绑定(防 SQLi 已满足);微信回调仅访问微信固定域名(无 SSRF 面)。 + +## 目录结构 + +``` +zt-closet-backend/ +├── configs/ +│ └── config.example.yaml # [MODIFY] 新增 WechatMini(AppID/AppSecret) 与 WechatPay(AppID/MchID/APIv3Key/SerialNo/PrivateKeyPath/NotifyURL/RefundNotifyURL) 示例段 +├── pkg/ +│ ├── setting/ +│ │ └── section.go # [MODIFY] 新增 WechatMiniSettingS、WechatPaySettingS 结构体 +│ └── wechat/ +│ ├── mini.go # [NEW] 微信小程序登录:Code2Session(ctx,code)->(openid,sessionKey,err),net/http 请求 jscode2session,errcode 映射 +│ └── pay.go # [NEW] 微信支付客户端:初始化 wechatpay-go(加载商户私钥/证书),封装 JsapiPrepay/BuildPaySign、VerifyAndDecryptPayNotify、Refund、VerifyRefundNotify +├── global/ +│ └── setting.go # [MODIFY] 新增 WechatMiniSetting、WechatPaySetting 全局变量 +├── main.go # [MODIFY] setupSetting 增 ReadSection("WechatMini"/"WechatPay");新增 setupWechatPayClient 初始化支付客户端(可选,失败仅告警不 panic) +└── internal/service/ + ├── service.go # [NEW] 公共工具:error->errcode 映射、redis.Nil 判定、分页默认值(page/pageSize)、时间/日期工具、consumeQuota/refundQuota 额度封装 + ├── constants.go # [NEW] 各状态/类型 int8 常量(用户/衣物处理状态/穿搭/分享/审核/额度类型/变更类型/订单/退款/通知/点赞收藏类型等) + ├── user.go # [NEW] UserService:WxLogin(真实code2session+注册+初始化额度+发Token)、InfoGetBySelf(缓存)、ProfileUpdate、RefreshToken + ├── quota.go # [NEW] QuotaService:InfoGetBySelf(remain=total-used)、FlowList;ensureDefaultQuota 新用户默认额度初始化 + ├── horoscope.go # [NEW] HoroscopeService:由生日算星座+当日日期,Redis→MySQL 读取今日运势,回填缓存 + ├── clothes.go # [NEW] ClothesService:Upload(扣额度+落COS+建记录+异步抠图分类)、ProcessStatusGet、GetByUUID(CDN URL)、UpdateByUUID、DeleteByUUID、List + ├── outfit.go # [NEW] OutfitService:Create/GetByUUID/Update/List/Delete/Public(公开自动审核通过+写审核日志+扣社区分享额度) + ├── closet.go # [NEW] ClosetShareService:Create(自动审核通过+扣衣柜分享额度)、Detail(过期/状态校验+访问计数)、List、Cancel + ├── collect.go # [NEW] CollectService:Collect(收藏/取消+Redis集合+target校验)、List(收藏穿搭列表) + ├── like.go # [NEW] LikeService:LikeCreate(点赞/取消+Redis+给作者发通知)、GetByTargetID(Redis优先计数) + ├── notification.go # [NEW] NotificationService:List/GetByUUID/Read/Unread;内部 sendNotification 供 like/order 复用 + ├── goods.go # [NEW] GoodsService:GetByCode(下架校验)、List(仅上架+类型过滤) + ├── order.go # [NEW] OrderService:Create(微信JSAPI下单)、PayNotify(发放会员/额度权益+通知)、Cancel、List、RefundApply、RefundNotify + └── airecommend.go # [NEW] AIRecommendService:Create(防并发+扣AI额度+大模型推荐+幂等流水+生成待保存穿搭)、Save(置为正常) +``` + +## 关键代码结构 + +``` +// internal/service/service.go —— service 层统一错误映射约定 +// 将底层 error 归一为语义化业务错误;redis.Nil 由调用方按未命中处理,不进入此函数。 +func toErr(err error, biz *errcode.Error) *errcode.Error + +// 额度封装(供 clothes/outfit/closet/airecommend 复用):MySQL 原子扣减为准 + 写流水 + 同步 Redis +func (s *QuotaService) consumeQuota(ctx context.Context, userID int64, quotaType int8, n int64, changeType int8, reason, orderNo string) *errcode.Error +func (s *QuotaService) refundQuota(ctx context.Context, userID int64, quotaType int8, n int64, reason string) *errcode.Error +``` + +``` +// pkg/wechat/pay.go —— 微信支付封装对外接口(签名以官方 SDK 实际类型为准,实现时对齐) +func InitPayClient(s *setting.WechatPaySettingS) error +func JsapiPrepay(ctx context.Context, openid, orderNo, description string, amount int64, notifyURL string) (prepayID string, err error) +func BuildMiniPayParams(prepayID string) *dto.WechatPayParams +func VerifyAndDecryptPayNotify(req *http.Request) (orderNo, transactionID string, paid bool, err error) +func Refund(ctx context.Context, orderNo, refundNo string, total, refund int64, notifyURL string) error +``` + +## Agent Extensions + +### SubAgent + +- **code-explorer** +- Purpose:实现阶段按需核对 DAO/Redis/pkg 的精确方法签名与字段(如 wechatpay-go 实际 API、`ClassifyResult` 字段到 `Clothes` 列的映射),避免签名臆测。 +- Expected outcome:跨文件签名/字段映射一次性确认到位,减少编译期返工。 \ No newline at end of file diff --git a/.codebuddy/plans/switch-matting-to-birefnet-lite_dfe3ba51.md b/.codebuddy/plans/switch-matting-to-birefnet-lite_dfe3ba51.md new file mode 100644 index 0000000..cac06a1 --- /dev/null +++ b/.codebuddy/plans/switch-matting-to-birefnet-lite_dfe3ba51.md @@ -0,0 +1,71 @@ +--- +name: switch-matting-to-birefnet-lite +overview: 将本地抠图服务从 ModelScope 标准版 BiRefNet(444MB)切换为 lite 版(chenmingyu/BiRefNet_lite,177MB),删除标准版权重,重启 8001 服务并用 image.png 重跑测试。 +todos: + - id: download-lite-model + content: 下载 lite 版 4 个文件到 models/BiRefNet_lite 目录 + status: completed + - id: remove-std-model-and-stop-service + content: 删除标准版目录并停止当前 8001 标准版服务 + status: completed + dependencies: + - download-lite-model + - id: restart-lite-service + content: 用 lite 目录重启 8001 服务并等待 /healthz 就绪 + status: completed + dependencies: + - remove-std-model-and-stop-service + - id: verify-lite-matting + content: 运行 go test 验证 image.png 抠图返回合法 PNG + status: completed + dependencies: + - restart-lite-service +--- + +## 用户需求 + +将抠图服务改为使用 BiRefNet_lite 权重,不使用其它版本(即弃用此前临时采用的 ModelScope 标准版 BiRefNet),并继续用 `image.png` 完成真实抠图验证。 + +## 核心内容 + +- 从 ModelScope 获取 transformers 格式的 lite 版模型文件(`chenmingyu/BiRefNet_lite`,权重约 177MB),跳过其中无用的 4.18GB `env.bin`。 +- 删除本地已下载的标准版 BiRefNet 模型目录,确保环境只保留 lite 版。 +- 停止当前以标准版运行的 8001 服务,改用 lite 版权重重启服务。 +- 重新运行 `go test ./pkg/matting/`,确认 `image.png` 抠图成功并返回合法 PNG。 + +## 技术栈 + +- Go 客户端:复用已实现的 `pkg/matting` 客户端(`Matting`/`HealthCheck`),无需改动。 +- Python 服务端:复用已适配的 `thirdparty/matting_server/main.py`,其 `_REPO_ID` 默认值为 `ZhengPeng7/BiRefNet_lite`,并已支持 CPU 下将 fp16 权重转 fp32,无需再改。 +- 模型获取:ModelScope 直链 `https://www.modelscope.cn/models/chenmingyu/BiRefNet_lite/resolve/master/`(当前网络可访问)。 + +## 实现方案 + +### 关键决策 + +- **仅下载 lite 版 4 个必需文件**:`birefnet.py`、`BiRefNet_config.py`、`config.json`、`model.safetensors`。跳过 `env.bin`(4.18GB,非加载必需),避免无谓的带宽与磁盘占用。 +- **删除标准版目录**:`thirdparty/matting_server/models/BiRefNet/`(444MB),满足用户"不要别的版本"的要求。 +- **优雅停服方式**:本环境 `kill`/`pkill` 命令会触发不可见的审批超时,改用 `python3 -c "import os,signal; os.kill(, signal.SIGTERM)"` 停止当前 8001 服务。 +- **服务启动**:通过 `BIREFNET_REPO_ID=/root/zt-closet-backend/thirdparty/matting_server/models/BiRefNet_lite` 环境变量指向 lite 目录启动,`/healthz` 就绪后再跑测试。 + +### 执行步骤 + +1. 创建 `thirdparty/matting_server/models/BiRefNet_lite/` 目录,用 curl 依次下载 4 个关键文件;校验 `model.safetensors` 大小约为 177634392 字节。 +2. 删除标准版目录 `models/BiRefNet/`;用 `python os.kill` 停止当前 8001 服务进程。 +3. 以 lite 目录重启服务,轮询 `/healthz` 直到返回 `{"status":"ok"}`。 +4. 执行 `go test ./pkg/matting/ -run TestMatting -v -timeout 600s`,断言输出非空且以 PNG 魔数开头。 + +### 涉及文件与目录 + +``` +thirdparty/matting_server/models/BiRefNet_lite/ # [NEW] lite 版模型文件(birefnet.py、BiRefNet_config.py、config.json、model.safetensors) +thirdparty/matting_server/models/BiRefNet/ # [DELETE] 标准版模型目录(444MB,用户要求移除) +pkg/matting/matting.go # 复用,不改动 +thirdparty/matting_server/main.py # 复用,不改动 +``` + +### 注意事项 + +- venv 及依赖已就绪(`thirdparty/matting_server/.venv/`),无需重装。 +- `model.safetensors` 与 `models/` 目录均已被 `.gitignore` 忽略,不会误提交。 +- 服务启动后确认日志出现 "BiRefNet 命中本地缓存,离线加载完成",且推理不再出现 fp16/fp32 dtype 报错。 \ No newline at end of file diff --git a/.codebuddy/plans/tokenhub_与_model_对齐优化_b2391cd4.md b/.codebuddy/plans/tokenhub_与_model_对齐优化_b2391cd4.md new file mode 100644 index 0000000..28d9932 --- /dev/null +++ b/.codebuddy/plans/tokenhub_与_model_对齐优化_b2391cd4.md @@ -0,0 +1,91 @@ +--- +name: tokenhub 与 model 对齐优化 +overview: 优化 tokenhub 包:让穿搭推荐充分利用 Clothes 模型字段、将品类枚举统一为 6 大类、整理运势映射与公共结构,使代码与 mysqlmodel 数据模型对齐。 +todos: + - id: refactor-client + content: 更新 client.go 包注释并将 chatResponse 结构下沉至 client.go + status: completed + - id: fortune-model-align + content: 为 FortuneItem 新增 ToHoroscope 方法并让 scheduler.go 改用 + status: completed + - id: outfit-fields-enum + content: 重写 FormatClothesInfo 补全字段并统一六类品类枚举与 prompt/schema + status: completed + - id: outfit-validate + content: 增强 ValidateClothesIDs 类型对齐与去重,GetClothesIDs 去重 + status: completed + dependencies: + - outfit-fields-enum + - id: verify-build + content: 执行 go build 与 go vet 验证 tokenhub 包编译通过 + status: completed + dependencies: + - refactor-client + - fortune-model-align + - outfit-fields-enum + - outfit-validate +--- + +## 产品概述 + +对 `pkg/tokenhub` 包做一次与数据模型(`mysqlmodel`)对齐的代码优化,使穿搭推荐和星座运势生成更贴合现有 `Clothes`、`Horoscope` 等模型定义,提升 LLM 推荐质量与代码可维护性。仅改动 tokenhub 包内代码,不接入新接口、不修改 `main.go`。 + +## 核心功能 + +- **穿搭推荐字段补全**:将传给大模型的衣物摘要从 8 个字段扩充到 `Clothes` 模型的完整穿搭属性(副色、厚度、袖型、衣长、领型、正式度、场合、视觉冲击力、名称等),并采用「字段名:值」标注格式,让模型准确理解每项属性。 +- **品类枚举统一为 6 大类**:将推荐品类从「上装/下装/鞋」扩展并统一为 `Clothes.CategoryMain` 的 6 大类(上装/下装/连衣裙/外套/鞋/配饰),同步更新结构体注释、prompt 搭配规则与 JSON Schema 枚举,消除「鞋/鞋子」措辞不一致。 +- **推荐结果校验增强**:校验大模型返回的 `clothes_id` 是否存在,并按实际衣物 `CategoryMain` 对齐 `type` 字段,同时对重复衣物去重。 +- **运势映射收敛**:为 `FortuneItem` 增加到 `Horoscope` 模型的转换方法,将散落在调度器中的字段映射逻辑收拢到模型转换处。 +- **公共结构整理**:将共享的 `chatResponse` 响应结构下沉到 `client.go`,并更新过时的包注释。 + +## 视觉与交互 + +本次为纯逻辑层优化,不涉及界面或交互变化。 + +## 技术栈 + +- 语言:Go(沿用现有项目技术栈) +- 依赖:仅使用标准库(`encoding/json`、`strings`、`fmt` 等),不新增任何外部依赖 +- 包结构:仅修改 `/root/zt-closet-backend/pkg/tokenhub/` 下 4 个文件,`mysqlmodel` 仅作只读引用 + +## 实现方法 + +### 总体策略 + +以 `mysqlmodel` 的字段定义为单一事实来源,消除 tokenhub 包中的硬编码字段列表与品类枚举漂移:衣物信息统一通过「字段名标注 + 全量字段」输出,品类统一通过包级常量切片管理,运势字段映射统一通过模型转换方法收敛。 + +### 关键决策 + +1. **品类枚举集中管理**:新增包级常量 `outfitCategories`,作为 prompt 文案、JSON Schema `enum`、类型校验的唯一来源,避免多处字符串硬编码不一致。 +2. **衣物摘要字段名标注**:`FormatClothesInfo` 改为 `- ID:1 | 大类:上装 | 小类:T恤 | ...` 格式,补全 `ColorSub/Thickness/SleeveType/ClothesType/NecklineType/Formality/Occasions/VisualImpact/Name` 等字段;继续使用 `strings.Builder` 拼接以保证性能。 +3. **推荐结果以 model 为准做纠偏**:`ValidateClothesIDs` 先按 `clothes_id` 是否在衣橱过滤,再将保留项的 `Type` 规范化为衣橱中该衣物实际的 `CategoryMain`(比简单丢弃更鲁棒),最后按 `clothes_id` 去重;`GetClothesIDs` 同步去重。均使用 `map` 保证 O(n) 复杂度。 +4. **运势映射下沉到模型转换方法**:为 `FortuneItem` 增加 `ToHoroscope(statDate int64) *mysqlmodel.Horoscope`,`scheduler.go` 中的逐字段构造改为调用该方法,职责更清晰。 +5. **保持导出 API 兼容**:`GenerateDailyFortunes`、`GenerateOutfitRecommendation`、`FormatClothesInfo` 的函数签名与返回类型不变,仅改内部实现,避免影响未来接入方。 + +### 性能与可靠性 + +- 所有列表处理均为单次遍历,`map` 去重 O(n),无额外性能负担。 +- 不引入网络调用、不改变模型加载/调用流程,对启动与运行期无副作用。 +- 新增的 `ToHoroscope` 与 `outfitCategories` 均无并发写入,可安全复用。 + +## 目录结构 + +仅改动 `pkg/tokenhub/` 下 4 个文件: + +``` +pkg/tokenhub/ +├── client.go # [MODIFY] 更新 package 注释(补充穿搭推荐);新增 chatResponse 通用响应结构 +├── fortune.go # [MODIFY] 移除 chatResponse;FortuneItem 新增 ToHoroscope 方法;统一「混元→Tokenhub」措辞 +├── outfit_recommend.go # [MODIFY] 新增 outfitCategories 常量;重写 FormatClothesInfo;更新 prompt/schema/Type 注释为 6 大类;增强 ValidateClothesIDs 与 GetClothesIDs +└── scheduler.go # [MODIFY] runFortuneGeneration 改用 FortuneItem.ToHoroscope 构造 Horoscope +``` + +## 关键代码结构 + +``` +// outfitCategories 穿搭推荐支持的衣物大类,与 mysqlmodel.Clothes.CategoryMain 取值对齐。 +var outfitCategories = []string{"上装", "下装", "连衣裙", "外套", "鞋", "配饰"} + +// ToHoroscope 将 LLM 返回的运势项转换为 Horoscope 模型。 +func (f FortuneItem) ToHoroscope(statDate int64) *mysqlmodel.Horoscope +``` \ No newline at end of file diff --git a/.codebuddy/plans/tokenhub_穿搭推荐_prompt_与输出字段对齐_020be2fa.md b/.codebuddy/plans/tokenhub_穿搭推荐_prompt_与输出字段对齐_020be2fa.md new file mode 100644 index 0000000..a42d406 --- /dev/null +++ b/.codebuddy/plans/tokenhub_穿搭推荐_prompt_与输出字段对齐_020be2fa.md @@ -0,0 +1,102 @@ +--- +name: tokenhub 穿搭推荐 prompt 与输出字段对齐 +overview: 优化 tokenhub 穿搭推荐的 prompt 与输出结构:补充「适合场景 scene」和「衣物 UUID clothes_uuid」字段,使推荐结果对齐 mysqlmodel.Outfit/OutfitItem 与 dto.AIRecommendCreateResponse。 +todos: + - id: align-fields + content: 补充 RecommendLLMResponse.Scene 与 RecommendLLMItem.ClothesUUID 字段,并在 FormatClothesInfo 中输出 UUID + status: completed + - id: optimize-prompt + content: 优化 buildRecommendPrompt:明确 style/scene 语义、更新 JSON 示例并加入 clothes_uuid 精确复制约束 + status: completed + dependencies: + - align-fields + - id: sync-schema + content: 同步 buildRecommendSchema:新增 scene 与 items.clothes_uuid 字段及 required 描述 + status: completed + dependencies: + - align-fields + - id: enhance-validate + content: 增强 ValidateClothesIDs:以衣橱实际数据回填正确的 ClothesUUID,保留品类对齐与去重 + status: completed + dependencies: + - align-fields + - id: verify-build + content: 执行 go build 与 go vet 验证 tokenhub 包编译与静态检查通过 + status: completed + dependencies: + - align-fields + - optimize-prompt + - sync-schema + - enhance-validate +--- + +## 产品概述 + +完善 tokenhub 穿搭推荐的结果字段,使大模型输出可直接对齐 `mysqlmodel.Outfit/OutfitItem` 与 `dto.AIRecommendCreateResponse`,重点优化穿搭推荐的 prompt 与 JSON Schema,让推荐结果补齐「适合场景 Scene」与「衣物 ClothesUUID」两个关键字段。 + +## 核心功能 + +- **新增「适合场景 Scene」字段**:推荐结果在保留风格 `Style` 的基础上,新增 `Scene`(适合场景),与 `Outfit.Scene`、`dto.AIRecommendCreateResponse.Scene` 对齐。 +- **新增「衣物 ClothesUUID」字段**:推荐单品在保留 `ClothesID` 的基础上,新增 `ClothesUUID`,与 `dto.OutfitItem.ClothesUUID` 对齐。 +- **衣物摘要补充 UUID**:`FormatClothesInfo` 输出衣橱衣物时同时给出 `ID` 与 `UUID`,供大模型精确引用。 +- **优化 prompt**:明确区分 `style`(风格:简约/复古/街头等)与 `scene`(适合场景:通勤/约会/休闲/运动等),要求分别输出;要求 `clothes_uuid` 从衣橱信息中精确复制并与 `clothes_id` 一一对应。 +- **同步 JSON Schema**:补充 `scene` 与 `items.clothes_uuid` 字段定义及必填约束。 +- **校验回填 UUID**:`ValidateClothesIDs` 以衣橱实际数据为准回填正确的 `ClothesUUID`,防止大模型抄错长 UUID,同时保留品类对齐与去重逻辑。 + +## 约束 + +- 仅修改 `/root/zt-closet-backend/pkg/tokenhub/outfit_recommend.go`。 +- 不改 `main.go`、不接接口、不改配置、不新增依赖。 +- 导出函数与方法签名保持不变。 + +## 技术栈 + +- 语言:Go(沿用现有项目技术栈) +- 依赖:仅标准库(`fmt`、`strings`、`encoding/json`),不新增任何外部依赖 +- 改动文件:仅 `pkg/tokenhub/outfit_recommend.go` + +## 实现方法 + +### 总体策略 + +以 `mysqlmodel.Outfit/OutfitItem` 与 `dto.AIRecommendCreateResponse/OutfitItem` 为字段对齐目标,对 tokenhub 包内的推荐结构体、衣物摘要格式、prompt、JSON Schema 与结果校验做一次一致性完善。核心是让 LLM 输出结果具备可直接映射到模型层的 `Scene` 与 `ClothesUUID` 字段,同时通过校验逻辑兜底纠偏。 + +### 关键决策 + +1. **`Style` 与 `Scene` 并存**:`Style` 表示穿搭风格(简约/复古/街头/甜美等),`Scene` 表示适合场景(通勤/约会/休闲/运动等)。两者语义不同,在 prompt 中明确区分并要求分别输出,避免模型把「风格」和「场合」混为一谈。 +2. **`ClothesID` 与 `ClothesUUID` 并存**:保留 `ClothesID`(整数,便于模型快速引用与校验),新增 `ClothesUUID`(对齐 dto 的落库字段)。由于 UUID 较长且易抄错,`ValidateClothesIDs` 在过滤后**以衣橱实际数据回填正确的 `ClothesUUID`**,而非信任模型输出,保证最终结果可靠。 +3. **`FormatClothesInfo` 并列输出 `ID` 与 `UUID`**:在现有「字段名:值」标注格式基础上,首列增加 `UUID`,形成 `- ID:1 | UUID:xxx | 大类:上装 | ...`,让模型能同时看到两个标识并建立对应关系。 +4. **保持导出 API 兼容**:`GenerateOutfitRecommendation`、`FormatClothesInfo`、`ValidateClothesIDs`、`GetClothesIDs` 等签名不变,仅改内部实现与结构体字段,避免影响未来接入方。 + +### 性能与可靠性 + +- `ValidateClothesIDs` 继续使用 `map[int64]mysqlmodel.Clothes` 建立 ID→衣物映射,过滤、type 对齐、UUID 回填、去重均在单次遍历内完成,整体 O(n),无额外性能负担。 +- 不改变网络调用、模型参数与调度流程,对运行期无副作用。 + +## 目录结构 + +``` +pkg/tokenhub/ +└── outfit_recommend.go # [MODIFY] 完善推荐结构体字段、衣物摘要、prompt、JSON Schema 与结果校验 +``` + +## 关键代码结构 + +``` +// RecommendLLMResponse 大模型返回的穿搭推荐。 +type RecommendLLMResponse struct { + OutfitName string `json:"outfit_name"` + Description string `json:"description"` + Style string `json:"style"` // 穿搭风格 + Scene string `json:"scene"` // 适合场景 + Items []RecommendLLMItem `json:"items"` +} + +// RecommendLLMItem 大模型返回的单品推荐。 +type RecommendLLMItem struct { + Type string `json:"type"` // 品类:上装/下装/连衣裙/外套/鞋/配饰 + ClothesID int64 `json:"clothes_id"` // 衣橱衣物 ID + ClothesUUID string `json:"clothes_uuid"` // 衣橱衣物 UUID + Reason string `json:"reason"` // 选择理由 +} +``` \ No newline at end of file diff --git a/.codebuddy/plans/修复XPay发货推送伪造漏洞_9bf015a6.md b/.codebuddy/plans/修复XPay发货推送伪造漏洞_9bf015a6.md new file mode 100644 index 0000000..89d333b --- /dev/null +++ b/.codebuddy/plans/修复XPay发货推送伪造漏洞_9bf015a6.md @@ -0,0 +1,111 @@ +--- +name: 修复XPay发货推送伪造漏洞 +overview: 修复 S1:XPay 发货推送在发放权益前增加微信侧订单真实性反查(QueryOrder),杜绝伪造推送资损;S2:彻底删除服务端自动道具同步逻辑(避免每次启动自动同步),道具同步交由用户管理后台负责,并更新 README 标注。 +todos: + - id: add-paid-status-helper + content: 在 pkg/wechat/virtualpay.go 新增已支付状态常量与 IsOrderPaidStatus 白名单判断函数,并在 virtualpay_test.go 补单测(已支付/未支付/未知/空串) + status: completed + - id: add-reality-check + content: 在 pkg/errcode/order_code.go 新增 70009,并在 internal/service/virtualpay.go 的 handleGoodsDeliver 本地校验后插入 verifyXPayOrderPaid 反查(fail-closed,附审计日志) + status: completed + dependencies: + - add-paid-status-helper + - id: remove-sync-logic + content: 删除服务端自动道具同步逻辑:main.go 调用、service 同步函数与常量、wechat 道具管理函数/类型/常量、goods.go 的 GoodsListVirtualSync 与 GoodsUpdateByID + status: completed + - id: update-docs + content: 更新 README.md 标注道具同步运营约束并删除后台任务表同步行,同步更新 docs/代码审查报告-二次check.md 中 S1 与 S2 处置状态 + status: completed +--- + +## 用户需求 + +1. **修复 S1(资损漏洞)**:XPay 发货推送无验签且缺少订单真实性反查,攻击者可伪造 `xpay_goods_deliver_notify` 推送使订单 Pending→Paid 零成本获取权益。修复方式:发货前调用带 HMAC-SHA256 签名的微信服务端 API `query_order` 反查订单真实支付状态,确认「已支付 + 金额一致 + 道具一致」后才流转发放,推送仅作触发信号。 + +2. **S2 删除服务端自动同步逻辑**:删除服务启动时自动执行的虚拟支付道具同步代码,避免每次运行服务端自动同步;道具同步改由用户自己的管理后台负责。同时在 README 标注运营约束:运营改价后必须通过管理后台同步道具(或重启服务),否则微信侧道具价格与本地不一致会导致发货金额校验拒绝(用户付款拿不到权益)或差价资损。 + +## 范围边界 + +- 仅修改虚拟支付链路:发货推送处理(`handleGoodsDeliver`)增加真实性反查;普通微信支付 V3 回调链路(官方 SDK 验签)不涉及。 +- 删除服务端道具同步链路(main 触发、service 同步逻辑、wechat 道具管理函数、同步专用 DAO),保留商品表 `product_id`/`product_status` 字段与 `GoodsProduct*` 状态常量(下单校验依赖)。 +- 退款推送伪造风险与 M1(RefundStatus 空串误判)不在本次范围。 + +## 技术方案 + +### S1:发货推送真实性反查 + +**策略**:在 `handleGoodsDeliver` 中,幂等锁内、全部本地校验(`pay_channel` / `product_id` / `amount` / `env`)通过之后、条件流转(Pending→Paid)之前,插入微信侧订单反查。反查失败或结果不符时返回非 nil 错误码,使 `XPayNotify` 回 `ErrCode:1`,微信按推送重试策略重试(fail-closed)。 + +**关键设计**: + +1. 支付状态白名单判断(`pkg/wechat` 层集中管理): + +- 新增「已支付」状态常量与 `IsOrderPaidStatus(status string) bool` 白名单判断函数;未知状态/空串按未支付处理并告警。 +- `virtualpay_test.go` 补充单测:已支付 / 未支付 / 未知状态 / 空串。 + +2. 反查校验函数(service 层): + +- 新增 `verifyXPayOrderPaid(ctx, order, deliver) *errcode.Error`,校验顺序: + - `deliver.OpenID` 为空 → 拒绝 + - `wechat.QueryOrder(ctx, deliver.OpenID, deliver.OutTradeNo, "")` 接口错误 → 拒绝 + - `!wechat.IsOrderPaidStatus(info.Status)` → 拒绝 + - `info.PayFee != order.PayAmount` → 拒绝 + - `info.ProductID != order.XPayProductID` → 拒绝 + - 全部通过 → nil +- 插入位置:`handleGoodsDeliver` 中 env 校验之后、`OrderUpdateStatusByOrderNo(Pending→Paid)` 之前。 + +3. 错误码: + +- `pkg/errcode/order_code.go` 新增 `OrderRealityCheckFailed = NewError(70009, "订单真实性校验失败")`。 + +4. 安全性与一致性: + +- 反查走 `callXPay` 的 HMAC 签名(激活环境 AppKey),攻击者无法伪造;伪造推送在微信侧无支付记录,反查必然失败 → 拒绝发货。 +- 真实支付后反查必能查到已支付,微信推送重试保证最终发货。 +- 反查使用激活环境,与订单 `XPayEnv` 快照经本地校验一致。 +- 成功路径沿用现有 `finalizeVirtualPaidOrder`(callback_raw 持久化原始推送),审计闭环不变。 +- 审计日志携带 `out_trade_no` / query 返回状态 / 金额,失败不落库(最小改动)。 + +**性能**:反查仅在幂等锁内执行一次(每笔发货推送一次 HTTP 调用,10s 超时),无额外热点。沙箱环境 `query_order` 行为需联调确认(代码注释保持「以官方文档为准」风格)。 + +### S2:删除服务端自动道具同步逻辑 + +**删除内容**: + +- `main.go`:`service.StartVirtualGoodsSync()` 调用及注释 +- `internal/service/virtualpay.go`:`StartVirtualGoodsSync` / `SyncVirtualGoods` / `syncOneGoods` / `pollGoodsTask` 及 4 个同步常量 +- `pkg/wechat/virtualpay.go`:`StartUploadGoods` / `QueryUploadGoods` / `StartPublishGoods` / `QueryPublishGoods`、`XPayGoodsItem` / `XPayTaskResult`、4 个道具 path 常量 +- `internal/mysqldao/goods.go`:`GoodsListVirtualSync` / `GoodsUpdateByID`(删除后无引用) + +**保留内容**: + +- 商品表 `product_id` / `product_status` 字段(管理后台回填,下单校验依赖) +- `GoodsProductUnpublished/Publishing/Published/PublishFail` 常量(下单校验与状态语义一致) +- `setupVirtualPayClient` / `GetVirtualPayEnv` / `QueryOrder`(下单、反查、回调仍需客户端) +- `GoodsGetByCode` / `GoodsGetByID` / `GoodsList`(商品模块与下单使用) + +**README 标注**: + +- 道具同步由管理后台负责,服务端不再自动同步 +- 运营改价后必须通过管理后台同步道具(或重启服务),否则微信侧价格与本地不一致会导致发货金额校验拒绝或差价资损 +- 删除后台任务表中「虚拟支付道具同步」行,同步更新交易与权益流程相关描述 + +### 文件修改清单 + +``` +zt-closet-backend/ +├── pkg/wechat/virtualpay.go # [MODIFY] 新增已支付状态常量与 IsOrderPaidStatus;删除道具管理函数/类型/path 常量 +├── pkg/wechat/virtualpay_test.go # [MODIFY] 补 IsOrderPaidStatus 单测 +├── pkg/errcode/order_code.go # [MODIFY] 新增 70009 错误码 +├── internal/service/virtualpay.go # [MODIFY] 插入 verifyXPayOrderPaid 反查;删除同步逻辑与常量 +├── internal/mysqldao/goods.go # [MODIFY] 删除 GoodsListVirtualSync / GoodsUpdateByID +├── main.go # [MODIFY] 删除 StartVirtualGoodsSync 调用 +├── README.md # [MODIFY] 标注道具同步运营约束,删除后台任务表同步行 +└── docs/代码审查报告-二次check.md # [MODIFY] S1 标「已修复」,S2 标「已删除自动同步,由管理后台负责」 +``` + +### 实现注意事项 + +- 删除同步逻辑后需确认 `internal/service/virtualpay.go` 的 `time` 包仍被锁 TTL 使用、`wechat` 包仍被 BuildPaymentSign 使用,避免编译错误。 +- 删除 `XPayGoodsItem` 后需确认无其他文件引用(搜索已确认仅同步逻辑引用)。 +- 反查状态白名单的枚举值以微信官方 `query_order` 返回状态为准,实现时集中定义常量并注释「联调校正」。 \ No newline at end of file diff --git a/.codebuddy/plans/修复XPay发货推送伪造漏洞_a31999c1(未完成).md b/.codebuddy/plans/修复XPay发货推送伪造漏洞_a31999c1(未完成).md new file mode 100644 index 0000000..ded832f --- /dev/null +++ b/.codebuddy/plans/修复XPay发货推送伪造漏洞_a31999c1(未完成).md @@ -0,0 +1,81 @@ +--- +name: 修复XPay发货推送伪造漏洞 +overview: 修复 S1:XPay 发货推送在条件流转发放权益前增加微信侧订单真实性反查(QueryOrder),杜绝伪造推送零成本获取权益;S2 不做代码修复,仅在 README 标注道具价格同步由管理后台负责的运维约束。 +todos: + - id: add-paid-status-helper + content: 在 pkg/wechat/virtualpay.go 新增「已支付」状态常量与 IsOrderPaidStatus 白名单判断函数,并在 virtualpay_test.go 补充单测(含未知状态、空串) + status: pending + - id: add-reality-check + content: 在 pkg/errcode/order_code.go 新增 70009 错误码,并在 internal/service/virtualpay.go 的 handleGoodsDeliver 中本地校验后、条件流转前插入 QueryOrder 反查(fail-closed,openid 为空/接口错误/未支付/金额道具不符均返回错误让微信重试,附审计日志) + status: pending + dependencies: + - add-paid-status-helper + - id: update-docs + content: 更新 README.md 标注道具同步运营约束(管理后台负责、改价必须同步及其风险),并同步更新 docs/代码审查报告-二次check.md 中 S1「已修复」、S2「已确认由管理后台同步,仅文档标注」 + status: pending +--- + +## 用户需求 + +1. **修复 S1(资损漏洞)**:XPay 发货推送无验签,攻击者可伪造 `xpay_goods_deliver_notify` 推送零成本获取权益。按已确认方案修复:发货前调用带 HMAC-SHA256 签名的微信服务端 API `query_order` 反查订单真实支付状态,确认「已支付 + 金额一致 + 道具一致」后才流转发放,推送仅作触发信号。 +2. **S2 仅文档标注**:不做道具周期同步代码。用户有自己的管理后台负责道具同步,仅在 README 标注运营约束:运营改价后必须通过管理后台同步道具(或重启服务),否则微信侧道具价格与本地不一致,会导致发货金额校验拒绝(用户付款拿不到权益)或差价资损。 + +## 范围边界 + +- 仅修改虚拟支付链路:发货推送处理(`handleGoodsDeliver`)增加真实性反查;普通微信支付 V3 回调链路(官方 SDK 验签)不涉及 +- 退款推送伪造风险与 M1(RefundStatus 空串误判)不在本次范围 +- 交付物:修复后的代码 + 单测 + README 与审查报告文档更新 + +## 技术方案 + +### 实现策略 + +在 `handleGoodsDeliver` 中,幂等锁内、全部本地校验(pay_channel / product_id / amount / env)通过之后、条件流转(Pending→Paid)之前,插入微信侧订单真实性反查。反查失败或结果不符时返回非 nil 错误码,使 `XPayNotify` 回 `ErrCode:1`,微信按推送重试策略重试(fail-closed)。 + +### 关键设计 + +**1. 支付状态白名单判断(pkg/wechat 层,与微信枚举集中管理)** + +- 在 `pkg/wechat/virtualpay.go` 新增导出函数 `IsOrderPaidStatus(status string) bool` 与状态常量(如 `xpayOrderStatusPaid`,枚举值以官方文档为准,联调校正) +- 未知状态按「未支付」处理并告警,保证 fail-closed +- `internal/service/virtualpay.go` 调用该函数,不散落魔法字符串 + +**2. 反查校验函数(service 层)** + +- 新增 `verifyXPayOrderPaid(ctx, order, deliver) *errcode.Error`: +- `deliver.OpenID` 为空 → 直接拒绝(返回新错误码 70009),审计日志 +- 调用 `wechat.QueryOrder(ctx, deliver.OpenID, deliver.OutTradeNo, "")` +- 接口错误 → 返回非 nil 错误(fail-closed),日志携带 out_trade_no 与 error +- `!wechat.IsOrderPaidStatus(info.Status)` → 拒绝 + 日志(含 query 返回状态) +- `info.PayFee != order.PayAmount` → 拒绝 + 日志(金额不符,资损线索) +- `info.ProductID != order.XPayProductID` → 拒绝 + 日志(道具不符) +- 全部通过 → 返回 nil,继续原有条件流转与 `finalizeVirtualPaidOrder` 发货 +- 插入位置:`handleGoodsDeliver` 中 env 校验之后、`OrderUpdateStatusByOrderNo(Pending→Paid)` 之前 + +**3. 错误码** + +- `pkg/errcode/order_code.go` 新增 `OrderRealityCheckFailed = NewError(70009, "订单真实性校验失败")`,语义用于「反查未通过/异常」,触发微信重试 + +**4. 安全性与一致性** + +- 反查走 `callXPay` 的 HMAC 签名(激活环境 AppKey),攻击者无法伪造;伪造推送在微信侧无支付记录,反查必然失败 → 拒绝发货 +- 真实支付后微信必然推送且反查必可查到已支付,重试机制保证最终发货 +- 反查使用激活环境(现网/沙箱),与订单 `XPayEnv` 快照已通过本地校验一致 +- 成功路径沿用现有 `finalizeVirtualPaidOrder`(callback_raw 持久化原始推送),审计闭环不变 + +### 性能与稳定性 + +- 反查仅在幂等锁内执行一次(每笔发货推送一次 HTTP 调用,10s 超时),不引入额外热点 +- 沙箱环境 `query_order` 行为需联调确认(代码注释保持与现有「以官方文档为准」风格一致) + +### 文件修改清单 + +``` +zt-closet-backend/ +├── pkg/wechat/virtualpay.go # [MODIFY] 新增 XPay 订单已支付状态常量与 IsOrderPaidStatus 白名单判断函数 +├── pkg/wechat/virtualpay_test.go # [MODIFY] 补充 IsOrderPaidStatus 单测:已支付/未支付/未知状态/空串 +├── pkg/errcode/order_code.go # [MODIFY] 新增 70009 订单真实性校验失败错误码 +├── internal/service/virtualpay.go # [MODIFY] handleGoodsDeliver 插入 verifyXPayOrderPaid 反查(fail-closed)+审计日志 +├── README.md # [MODIFY] S2 标注:道具同步由管理后台负责,改价必须同步(风险说明) +└── docs/代码审查报告-二次check.md # [MODIFY] S1 标注「已修复」,S2 标注「已确认由管理后台同步,仅文档标注」 +``` \ No newline at end of file diff --git a/.codebuddy/plans/修复_redismodel_redisdao_语义与健壮性问题_P1_P2__832261d9.md b/.codebuddy/plans/修复_redismodel_redisdao_语义与健壮性问题_P1_P2__832261d9.md new file mode 100644 index 0000000..6948b53 --- /dev/null +++ b/.codebuddy/plans/修复_redismodel_redisdao_语义与健壮性问题_P1_P2__832261d9.md @@ -0,0 +1,111 @@ +--- +name: 修复 redismodel/redisdao 语义与健壮性问题(P1+P2) +overview: 修复 redismodel/redisdao 内部的日期类型不一致、Quota 缓存语义不清,以及扣减/读取的健壮性边界问题,不修改 middleware。 +todos: + - id: fix-quota-key-defs + content: 统一日期类型并重命名 redismodel/quota.go 的剩余配额 key + status: completed + - id: fix-quota-dao + content: 修改 redisdao/quota.go 函数签名、剩余配额语义与 Lua 扣减逻辑 + status: completed + dependencies: + - fix-quota-key-defs + - id: fix-notification-decr + content: 用 Lua 脚本修复 notification.go 未读数扣减防负 + status: completed + - id: verify-build + content: 运行 go build、go vet 与 lint 验证编译及诊断通过 + status: completed + dependencies: + - fix-quota-key-defs + - fix-quota-dao + - fix-notification-decr +--- + +## 产品概述 + +针对 `redismodel` 与 `redisdao` 二次校验后发现的 P1(语义/一致性)与 P2(健壮性边界)问题进行修复,仅限这两层内部改动,不动 `middleware`。 + +## 核心修复内容 + +- **P1.1 日期类型统一**:`quota` 模块每日计数 key 的日期由 `%s`(string)统一为 `%d`(int64 YYYYMMDD),与 `horoscope` 及 `mysqlmodel` 的 `StatDate int64` 对齐。 +- **P1.2 Quota 缓存语义澄清**:将 `KeyQuotaTotal`/`QuotaTotalSet/Get/Decr` 重命名为 `KeyQuotaRemain`/`QuotaRemainSet/Get/Decr`,明确 Redis 存的是「剩余可用配额」而非「总配额」,消除与 `mysqlmodel.Quota`(Total + Used)的语义错位。 +- **P2.1 扣减原子化与防负**:`QuotaRemainDecr` 改用 Lua 脚本原子扣减,key 不存在时不产生负数脏 key,剩余不足时不扣减。 +- **P2.2 每日计数未命中返回 0**:`quotaDailyValue` 对 `redis.Nil` 返回 `0, nil`,与 `NotificationUnreadGet` 行为一致。 +- **P2.3 未读计数扣减防负**:`NotificationUnreadDecr` 改用 Lua 脚本保证结果不低于 0。 + +## 技术栈 + +- 语言:Go +- Redis 客户端:`github.com/redis/go-redis/v9` +- 仅修改 3 个文件,沿用现有 `redismodel`(key 常量)+ `redisdao`(操作函数)分层与 `redis.NewScript` Lua 脚本模式。 + +## 实现方案 + +### P1.1 日期类型统一为 int64 + +`internal/redismodel/quota.go` 三个每日 key 的 `%s` 改为 `%d`,注释标注 `date(YYYYMMDD)`;`internal/redisdao/quota.go` 中 6 个 `QuotaDaily*` 函数签名 `date string` 改为 `date int64`。`fmt.Sprintf` 的 `%d` 直接接收 int64,无其它改动。 + +### P1.2 配额语义重命名为「剩余配额」 + +- `redismodel/quota.go`:`KeyQuotaTotal` 重命名为 `KeyQuotaRemain`,key 由 `quota:total:%d:%d` 改为 `quota:remain:%d:%d`,注释改为「剩余可用配额」。 +- `redisdao/quota.go`:`QuotaTotalSet/Get/Decr` 重命名为 `QuotaRemainSet/Get/Decr`,`QuotaRemainSet` 形参 `total` 改为 `remain`,函数注释同步说明存的是「剩余可用配额」。 + +### P2.1 配额扣减原子化(Lua) + +新增 `quotaRemainDecrScript`,`QuotaRemainDecr` 通过脚本执行,单次 RTT、Redis 端原子,避免并发超扣与脏 key: + +``` +local v = redis.call('GET', KEYS[1]) +if not v then return -1 end +v = tonumber(v) +local d = tonumber(ARGV[1]) +if v < d then return -2 end +return redis.call('DECRBY', KEYS[1], d) +``` + +返回值契约: + +- `-1`:配额未初始化(key 不存在) +- `-2`:剩余配额不足 +- `>= 0`:扣减成功后的剩余值 + +### P2.2 每日计数未命中返回 0 + +`quotaDailyValue` 改为: + +``` +v, err := global.RedisEngine.Get(ctx, key).Int64() +if err == redis.Nil { + return 0, nil +} +return v, err +``` + +### P2.3 未读数扣减防负(Lua) + +`NotificationUnreadDecr` 改用脚本,保证结果不低于 0: + +``` +local v = redis.call('GET', KEYS[1]) +if v and tonumber(v) > 0 then return redis.call('DECRBY', KEYS[1], 1) end +return 0 +``` + +## 目录结构 + +``` +internal/ +├── redismodel/ +│ └── quota.go # [MODIFY] 每日 key %s→%d;KeyQuotaTotal→KeyQuotaRemain(key quota:remain:%d:%d),注释澄清语义 +└── redisdao/ + ├── quota.go # [MODIFY] 6 个 QuotaDaily* date→int64;QuotaTotal*→QuotaRemain* 重命名;QuotaRemainDecr 用 Lua;quotaDailyValue 未命中返回 0 + └── notification.go # [MODIFY] NotificationUnreadDecr 用 Lua 防负 +``` + +## 实施要点 + +- **影响范围可控**:已确认 `QuotaDaily*`/`QuotaTotal*` 及 `KeyQuotaDaily*`/`KeyQuotaTotal` 无业务层调用方,重命名与签名变更自包含、无连带改动。 +- **兼容性**:这些 key 均为新增未上线代码,key 字符串变更(`quota:total`→`quota:remain`、日期 `%s`→`%d`)不涉及存量数据迁移。 +- **性能**:Lua 脚本均为单次往返、Redis 端原子执行,与既有 `quotaDailyIncrScript`/`unlockScript` 风格一致,无额外开销。 +- **日志**:本次改动不引入日志;错误沿现有 `error` 返回值上抛,由上层统一处理。 \ No newline at end of file diff --git a/.codebuddy/plans/修复中等与轻微问题6项_599caa55.md b/.codebuddy/plans/修复中等与轻微问题6项_599caa55.md new file mode 100644 index 0000000..a8598ce --- /dev/null +++ b/.codebuddy/plans/修复中等与轻微问题6项_599caa55.md @@ -0,0 +1,98 @@ +--- +name: 修复中等与轻微问题6项 +overview: 按二次审查报告修复 6 个问题:M1(退款推送 RefundStatus 空串误回收)、M2(发货校验失败返回成功应答吞资损线索)、M5(补偿任务 Failed 被回调重置)、M14(衣物失败回退与删除并发漏退)、M10(refresh-token access_token 文档矛盾)、M13(Outfit.Public 并发语义冲突)。限流(M11)与上传防护(M12)本次不修复。 +todos: + - id: fix-xpay-notify + content: 修复虚拟支付推送链路:新增 XPayRefundStatusSuccess 常量,handleRefundNotify 改白名单判断,handleGoodsDeliver 五个校验失败分支返回 70009 触发微信重试 + status: completed + - id: fix-benefit-reset + content: 修复补偿任务重置:BenefitGrantTaskReset 保留 retry_count,失败任务增加 5 分钟冷却期后才允许回调重新触发 + status: completed + - id: fix-clothes-refund-tx + content: 修复衣物回退漏退:markProcessFailed 重写为行锁单事务,删除 refundIfExists 与 ClothesMarkProcessFailedIfNotFailed + status: completed + - id: fix-outfit-public-lock + content: 修复穿搭公开竞态:redismodel 新增 KeyLockOutfitPublic,OutfitService.Public 全流程加 Redis 锁串行化 + status: completed + - id: sync-docs + content: 同步文档:README 修正 refresh-token access_token 为必填,审查报告 M1/M2/M5/M10/M13/M14 标注已修复 + status: completed + dependencies: + - fix-xpay-notify + - fix-benefit-reset + - fix-clothes-refund-tx + - fix-outfit-public-lock + - id: verify + content: 验证 go build 与 go test 通过,read_lints 无新增告警 + status: completed + dependencies: + - sync-docs +--- + +## 用户需求 + +根据二次代码审查报告,修复用户确认的 6 个问题(最新调整:限流缺失 M11、上传防护 M12 暂不修复): + +1. **M1 退款推送误回收**:`RefundStatus` 字段为空串或未知值时被误判为「退款成功」并回收权益,修复为仅当明确等于 `SUCCESS` 才回退 +2. **M2 发货校验失败静默**:发货推送本地校验失败(订单不存在/渠道不符/道具不符/金额不符/环境不符)返回成功应答,微信不重试且资损线索被吞掉,修复为返回失败应答触发重试 +3. **M5 补偿任务重试上限失效**:失败任务被支付回调重置(retry_count 清零),绕过重试上限与人工介入机制 +4. **M14 衣物额度漏退窗口**:异步处理失败回退与用户删除并发时,可能两个「应回退」事件叠加却零回退,用户损失额度 +5. **M10 refresh-token 文档矛盾**:README 标注 access_token 为可选,但代码要求必填,统一为必填 +6. **M13 穿搭公开并发语义冲突**:并发公开时,一个请求回退公开态后,另一请求已误报成功 + +## 约束 + +- 保持现有代码风格(slog 结构化日志、errcode 错误码、Redis fail-open 降级) +- 修复后 `go build ./...` 与 `go test ./pkg/wechat/` 必须通过,read_lints 无新增告警 +- 不修改普通微信支付 V3 回调链路;M3/M4/M6-M9/M11/M12/M15 及未点名问题不在本次范围 + +## 技术方案 + +### 1. M1 + M2:虚拟支付推送链路修复 + +**M1 退款推送白名单** + +- `pkg/wechat/virtualpay.go`:新增常量 `XPayRefundStatusSuccess = "SUCCESS"` +- `internal/service/virtualpay.go` 的 `handleRefundNotify`:改为 `if refund.RefundStatus != wechat.XPayRefundStatusSuccess { slog.WarnContext(...); return nil }`,仅成功状态走 `settleRefundSuccess`;订单不存在分支保持 `return nil` + +**M2 发货校验失败应答** + +- `handleGoodsDeliver` 中以下五个分支由 `return nil` 改为 `return errcode.OrderRealityCheckFailed`(70009 已存在),使 `XPayNotify` 回 `ErrCode:1` 让微信重试: +- 订单不存在 +- `pay_channel != PayChannelVirtual` +- 道具 ID 不匹配 +- 实付金额不匹配 +- 环境不匹配 +- 锁竞争失败分支保持 `return nil`(并发回调交由持锁者处理,防重试风暴) + +### 2. M5:补偿任务重置保护 + +- `internal/mysqldao/benefit_grant_task.go` 的 `BenefitGrantTaskReset`:删除 `retry_count: 0`,保留原重试计数 +- `internal/service/order.go` 的 `grantBenefitIdempotent` Failed 分支:新增冷却常量 `benefitRetryCooldownSeconds = 300`;当 `nowUnix()-existing.UpdatedAt < 300` 时仅打告警跳过,冷却期外才 Reset + executeGrant,恢复重试上限与人工介入机制 + +### 3. M14:衣物处理失败回退事务化 + +- `internal/service/clothes.go` 重写 `markProcessFailed` 为单事务(复用 `DeleteByUUID` 的 FOR UPDATE + 回退模式): + +1. `tx.Clauses(clause.Locking{Strength: "UPDATE"})` 按 `clothes_uuid` 查衣物;不存在/已软删(RecordNotFound)或已是失败态则提交并跳过 +2. 置 `image_process_status=5` + `fail_reason` +3. 同事务回退额度:条件更新 `used - 1`(used>=1),回读真实值,写回退流水(Reason="衣物处理失败回退",OrderNo=流水 UUID 兜底) + +- 删除 `refundIfExists` 方法与 `internal/mysqldao/clothes.go` 的 `ClothesMarkProcessFailedIfNotFailed`(已确认仅此一处调用) +- `DeleteByUUID` 保持不变:行锁读到失败态即跳过回退,与失败事务通过行锁互斥,两路径恰好回退一次 + +### 4. M13:穿搭公开加 Redis 锁 + +- `internal/redismodel/lock.go`:新增 `KeyLockOutfitPublic = RedisPrefix + "lock:outfit:public:%s"`(outfit_uuid) +- `internal/service/outfit.go` 的 `Public`:进入后先 `redisdao.TryLock(fmtLockKey(KeyLockOutfitPublic, request.OutfitUUID), 10s)`,失败返回 `errcode.TooManyRequests`;成功 `defer` 解锁;锁内保留现有「条件公开 → 扣额 → 失败回退」流程,串行化后并发请求语义正确 + +### 5. M10 + 文档同步 + +- `README.md` 686 行:access_token 由「否(可选,向前兼容)」改为「是(必填)」,说明缺少返回 10000 +- `docs/代码审查报告-二次check.md`:M1/M2/M5/M10/M13/M14 标题后标注「已修复」及一行修复说明;M11/M12 保持未修复状态 + +### 验证 + +- `go build ./...` 全量编译通过 +- `go test ./pkg/wechat/` 通过 +- `read_lints` 检查修改文件无新增告警 \ No newline at end of file diff --git a/.codebuddy/plans/修复中等与轻微问题8项_e909def3(未完成).md b/.codebuddy/plans/修复中等与轻微问题8项_e909def3(未完成).md new file mode 100644 index 0000000..fb87169 --- /dev/null +++ b/.codebuddy/plans/修复中等与轻微问题8项_e909def3(未完成).md @@ -0,0 +1,117 @@ +--- +name: 修复中等与轻微问题8项 +overview: 按二次审查报告修复 8 个问题:M1(退款推送 RefundStatus 空串误回收)、M2(发货校验失败返回成功应答吞资损线索)、M5(补偿任务 Failed 被回调重置)、M14(衣物失败回退与删除并发漏退)、M10(refresh-token access_token 文档矛盾)、M11(公开接口无限流)、M12(上传接口无 body 大小限制)、M13(Outfit.Public 并发语义冲突)。 +todos: + - id: fix-xpay-notify + content: 修复虚拟支付推送链路:新增 XPayRefundStatusSuccess 常量,handleRefundNotify 改白名单判断,handleGoodsDeliver 五个校验失败分支返回 70009 触发微信重试 + status: pending + - id: fix-benefit-reset + content: 修复补偿任务重置:BenefitGrantTaskReset 保留 retry_count,失败任务增加 5 分钟冷却期后才允许回调重新触发 + status: pending + - id: fix-clothes-refund-tx + content: 修复衣物回退漏退:markProcessFailed 重写为行锁单事务(置失败+回退+流水),删除 refundIfExists 与 ClothesMarkProcessFailedIfNotFailed + status: pending + - id: fix-outfit-public-lock + content: 修复穿搭公开竞态:redismodel 新增 KeyLockOutfitPublic,OutfitService.Public 全流程加 Redis 锁串行化 + status: pending + - id: add-ratelimit-upload-guard + content: 新增登录/刷新限流中间件(Redis 固定窗口 fail-open)并接入路由,上传接口加 MaxBytesReader 请求体限制 + status: pending + - id: sync-docs + content: 同步文档:README 修正 refresh-token access_token 为必填,审查报告 M1/M2/M5/M10/M11/M12/M13/M14 标注已修复并验证编译测试 + status: pending + dependencies: + - fix-xpay-notify + - fix-benefit-reset + - fix-clothes-refund-tx + - fix-outfit-public-lock + - add-ratelimit-upload-guard +--- + +## 产品概述 + +基于二次审查报告,修复用户确认的 8 个问题:虚拟支付退款误回收(M1)、发货校验失败静默(M2)、补偿任务重试上限失效(M5)、衣物额度漏退窗口(M14),以及限流缺失(M11)、上传接口无请求体限制(M12)、refresh-token 文档矛盾(M10)、穿搭公开并发语义冲突(M13)。全部为后端 Go 代码修复,不涉及 UI。 + +## 核心功能 + +- **M1 退款推送白名单**:仅当 `RefundStatus == "SUCCESS"` 才回收权益,空串/未知状态仅记录日志,杜绝误回收 +- **M2 发货校验失败应答**:发货推送本地校验失败(订单不存在/渠道不符/道具不符/金额不符/环境不符)返回失败应答触发微信重试,不再静默吞掉资损线索;并发锁竞争分支保持成功应答防重试风暴 +- **M5 补偿任务冷却保护**:任务重置不再清零重试计数,且失败任务 5 分钟冷却期外才允许被回调重新触发,重试上限与人工介入机制恢复有效 +- **M14 衣物回退事务化**:处理失败标记与额度回退合并为单事务(行锁互斥),消除与删除并发时的漏退窗口,保证恰好回退一次 +- **M11 登录/刷新限流**:新增基于 Redis 固定窗口的限流中间件(按 IP,Redis 故障 fail-open),仅应用于登录与刷新两个公开接口,回调接口不限流 +- **M12 上传请求体限制**:上传接口在 multipart 解析前用 MaxBytesReader 按配置上限拦截超大请求体 +- **M13 穿搭公开串行化**:公开/取消公开流程加 per-outfit Redis 锁,消除并发下「回退公开但另一请求误报成功」的语义冲突 +- **M10 文档修正**:README 中 refresh-token 的 access_token 改为必填,与代码实现一致 + +## 技术方案 + +### 1. M1 + M2:虚拟支付推送链路修复 + +- `pkg/wechat/virtualpay.go`:在退款推送结构附近新增常量 `XPayRefundStatusSuccess = "SUCCESS"` +- `internal/service/virtualpay.go` `handleRefundNotify`:条件改为 `if refund.RefundStatus != wechat.XPayRefundStatusSuccess { 记录日志; return nil }`,仅成功状态走 `settleRefundSuccess`;订单不存在分支保持 return nil(退款重试无意义) +- `handleGoodsDeliver`:order not found / pay_channel 不符 / product mismatch / amount mismatch / env mismatch 五个分支由 `return nil` 改为 `return errcode.OrderRealityCheckFailed`(70009 已存在),使 `XPayNotify` 回 `ErrCode:1` 让微信重试;锁竞争失败分支保持 `return nil`(防并发回调重试风暴) + +### 2. M5:补偿任务重置保护 + +- `internal/mysqldao/benefit_grant_task.go` `BenefitGrantTaskReset`:删除 `retry_count: 0`,保留原重试计数 +- `internal/service/order.go` `grantBenefitIdempotent` 的 Failed 分支:新增冷却常量 `benefitRetryCooldownSeconds = 300`;当 `nowUnix() - existing.UpdatedAt < 300` 时仅打告警日志跳过,冷却期外才 Reset + executeGrant,防止微信回调重试风暴无限重置任务 + +### 3. M14:衣物处理失败回退事务化 + +- `internal/service/clothes.go` 重写 `markProcessFailed` 为单事务(与 `DeleteByUUID` 同模式,已 import gorm/clause): + +1. `tx.Clauses(clause.Locking{Strength: "UPDATE"})` 按 clothes_uuid 查衣物;不存在/已软删(RecordNotFound)或已是失败态则 return nil(删除路径已回退或已处理) +2. 置 `image_process_status=5` + `fail_reason` +3. 同事务回退额度:条件更新 `used - 1`(used>=1),回读真实值,写回退流水(Reason="衣物处理失败回退",OrderNo=流水 UUID 兜底) + +- 删除 `refundIfExists` 方法与 `internal/mysqldao/clothes.go` 的 `ClothesMarkProcessFailedIfNotFailed`(已确认仅此一处调用) +- 删除路径 `DeleteByUUID` 保持不变:行锁读到失败态即跳过回退,与新事务通过行锁互斥,两路径恰好回退一次 + +### 4. M13:穿搭公开加 Redis 锁 + +- `internal/redismodel/lock.go` 新增 `KeyLockOutfitPublic = RedisPrefix + "lock:outfit:public:%s"`(outfit_uuid) +- `internal/service/outfit.go` `Public`:进入后先 `redisdao.TryLock(fmtLockKey(KeyLockOutfitPublic, outfitUUID), 10s)`,失败返回 `errcode.TooManyRequests`;成功后 defer 解锁;锁内保留现有「条件公开 → 扣额 → 失败条件回退」流程,串行化后并发请求语义正确 + +### 5. M11:限流中间件 + +- 新增 `internal/middleware/ratelimit.go`:`RateLimit(limit int, window time.Duration) gin.HandlerFunc` +- Redis 固定窗口:key = `redismodel.KeyRateLimit(IP)`,首次 `INCR` 后设 `EXPIRE window`,超过 limit 返回 `errcode.TooManyRequests`(10003,已映射 HTTP 429) +- Redis 异常时 fail-open 放行(与项目降级策略一致),仅告警 +- `internal/redismodel/` 新增 key 常量(如 `KeyRateLimit = RedisPrefix + "ratelimit:%s"`) +- `internal/routers/router.go`:对 `/user/wxlogin`、`/user/refresh-token` 两个路由应用,默认阈值常量 `每 IP 每 60 秒 30 次`;微信支付/xpay 回调路由不接限流(避免误伤真实回调) + +### 6. M12:上传请求体限制 + +- `internal/routers/api/v1/clothes.go` `Upload`:在 `c.FormFile` 前设置 `c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, int64(global.UploadSetting.MaxSizeMB+2)*1024*1024)`(留 multipart 边界余量) +- FormFile 失败时:错误信息含 "request body too large" 返回 `errcode.UploadFileTooLarge`(30002),否则保持 `errcode.UploadFileEmpty` + +### 7. M10 + 文档同步 + +- `README.md` 686 行:access_token 由「否(可选,向前兼容)」改为「是(必填)」,说明缺少返回 10000 +- `docs/代码审查报告-二次check.md`:M1/M2/M5/M10/M11/M12/M13/M14 标题后标注「已修复」及一行修复说明 + +### 验证 + +- `go build ./...` 全量编译通过 +- `go test ./pkg/wechat/` 通过 +- read_lints 检查修改文件无新增告警 + +## 关键代码结构 + +限流中间件签名: + +``` +// RateLimit 基于 Redis 固定窗口的按 IP 限流中间件(Redis 故障 fail-open)。 +func RateLimit(limit int, window time.Duration) gin.HandlerFunc +``` + +衣物失败回退事务核心流程: + +```text +tx 开始 + SELECT ... FOR UPDATE clothes(不存在/已删/已失败 → 提交并跳过) + UPDATE clothes SET image_process_status=5, fail_reason=? + UPDATE quota SET used=used-1 WHERE used>=1(条件失败 → 跳过流水) + 回读 quota → 写回退流水(唯一索引兜底) +tx 提交 +``` \ No newline at end of file diff --git a/.codebuddy/plans/修复中高等级代码问题_7e510a15.md b/.codebuddy/plans/修复中高等级代码问题_7e510a15.md new file mode 100644 index 0000000..567b79e --- /dev/null +++ b/.codebuddy/plans/修复中高等级代码问题_7e510a15.md @@ -0,0 +1,148 @@ +--- +name: 修复中高等级代码问题 +overview: 修复三次代码审查报告中「中等及以上」问题(排除限流 M11、虚拟订单垃圾堆积 M15):广告激励加配置开关+服务端定价、退款改为管理员操作、XPay 退款反查、vip_type 推导、iOS 退款问询、platform 校验、pay_time 兜底、上传请求体限制。 +todos: + - id: adreward-config + content: 新增 AdReward 配置段与错误码,改造广告激励为开关加服务端定价 + status: completed + - id: xpay-refund-verify + content: 新增微信侧退款查询并接入 XPay 退款推送反查 + status: completed + - id: refund-manual-review + content: 重写自动退款为待审核退款单并兼容已取消订单退款流转 + status: completed + - id: vip-type-derive + content: 改造 VIPGrantIdempotent 按最终到期时长推导会员类型 + status: completed + - id: virtualpay-fixes + content: 修复 iOS 退款问询、平台真实性校验与支付时间兜底 + status: completed + - id: upload-body-limit + content: 上传 handler 入口增加请求体大小限制 + status: completed + - id: update-report-verify + content: 更新审查报告修复状态并执行编译与静态检查验证 + status: completed + dependencies: + - adreward-config + - xpay-refund-verify + - refund-manual-review + - vip-type-derive + - virtualpay-fixes + - upload-body-limit +--- + +## 用户需求 + +针对三次代码审查报告,修复中等及以上问题(排除公开接口限流、虚拟支付订单禁取消带来的垃圾订单堆积两项),并落实两项产品调整: + +1. **广告激励配置化与防刷**:广告激励额度在 config 中配置「是否开启」;每种 reward_type 的发放金额改为服务端配置固定值,前端不再传金额,从根源杜绝刷额度。 +2. **退款管理员操作**:取消订单自动调微信退款的行为改为生成待审核退款单,由管理员在后台手动退款,所有退款均由管理员操作。 +3. **修复范围**:严重问题 S1-new(XPay 退款推送无反查)、S2-new(广告激励刷额度);中等问题 M1-new(自动退款卡死)、M2-new(会员类型覆盖)、M3(iOS 退款问询无条件建议退款)、M4(platform 可伪造)、M9(并发支付时间无兜底)、M12(上传请求体无大小限制)。 + +## 核心功能 + +- 广告激励开关关闭时接口返回明确的「未开启」错误;开启后按服务端配置金额发放,前端仅传奖励类型与广告记录 ID。 +- XPay 退款推送在处理前反查微信侧退款真实性,未通过拒绝发放并触发微信重试。 +- 取消后已付款订单生成待审核退款单,退款回调兼容「已取消」订单的退款流转,且不误回收从未发放的权益。 +- 会员续费时会员类型按最终到期时长自动推导,年度会员续月包不再被降级。 +- iOS 退款问询按权益消耗情况细化同意/拒绝。 +- 虚拟支付发货时校验微信侧真实平台,支付时间统一兜底。 +- 上传接口在解析 multipart 前限制请求体大小。 + +## 视觉/交互 + +本任务为后端逻辑修复,不涉及界面视觉与交互变更。 + +## 技术栈 + +- 语言:Go 1.27 +- 框架:Gin v1.12、GORM + MySQL(含 soft_delete)、go-redis v9、viper(仅读 YAML) +- 微信:微信支付 V3 官方 SDK、小程序虚拟支付(XPay)自研 HMAC-SHA256 签名客户端 +- 复用项目既有模式:`errcode` 语义化错误、事务幂等、best-effort 告警、`*int64` 配置指针区分「未配置/显式 0」 + +## 实现方式 + +按 8 个修复点分 6 组独立实施,均沿既有分层(handler → service → dao / pkg 客户端)推进,不改动架构: + +1. **广告激励(S2)**:新增 `AdReward` 配置段与错误码,DTO 移除金额字段,`Grant` 校验开关并从配置取金额。 +2. **XPay 退款反查(S1)**:`wechat` 层补退款状态查询,`handleRefundNotify` 在权益回退前反查微信侧真实性。 +3. **退款管理员操作(M1)**:`autoRefundCancelledOrder` 重写为待审核退款单,`settleRefundSuccess` 兼容「已取消」订单。 +4. **会员类型推导(M2)**:`VIPGrantIdempotent` 原生 SQL 按最终到期时长推导会员类型。 +5. **虚拟支付细节(M3/M4/M9)**:iOS 退款问询细化、平台真实性校验、支付时间兜底。 +6. **上传限制(M12)**:handler 入口加 `http.MaxBytesReader`。 + +## 架构设计 + +```mermaid +flowchart LR + A[config AdReward] --> B[adreward.Grant] + B --> C[QuotaGrant] + + D[XPay 退款推送] --> E[handleRefundNotify] + E --> F[QueryOrder 反查] + F --> G[settleRefundSuccess] + + H[PayNotify Cancelled] --> I[autoRefundCancelledOrder] + I --> J[待审核 RefundFlowLog] + + K[grantVIP] --> L[VIPGrantIdempotent SQL 推导类型] + + M[Upload handler] --> N[MaxBytesReader] --> O[service.Upload] +``` + +## 目录结构 + +以下为本次修改/新增的文件: + +``` +zt-closet-backend/ +├── configs/ +│ └── config.example.yaml # [MODIFY] 新增 AdReward 配置段(Enable + 各类型金额,含注释) +├── global/ +│ └── setting.go # [MODIFY] 注册 AdRewardSetting 全局指针 +├── main.go # [MODIFY] setupSetting() 读取 AdReward 段并做默认值兜底 +├── pkg/ +│ ├── setting/ +│ │ └── section.go # [MODIFY] 新增 AdRewardSettingS 结构体 +│ ├── errcode/ +│ │ └── quota_code.go # [MODIFY] 新增 AdRewardDisabled 错误码 +│ └── wechat/ +│ └── virtualpay.go # [MODIFY] XPayOrderInfo 补退款状态字段 + 退款状态判断 +└── internal/ + ├── dto/ + │ └── adreward.go # [MODIFY] 移除 Amount 字段 + ├── mysqldao/ + │ └── user.go # [MODIFY] VIPGrantIdempotent 按最终到期时长推导 vip_type + ├── routers/api/v1/ + │ └── clothes.go # [MODIFY] Upload handler 入口加 MaxBytesReader + └── service/ + ├── adreward.go # [MODIFY] Grant 校验开关 + 服务端定价 + ├── virtualpay.go # [MODIFY] 退款反查、iOS 问询细化、platform 校验、pay_time 兜底 + └── order.go # [MODIFY] autoRefundCancelledOrder 改待审核、settleRefundSuccess 兼容 Cancelled、grantVIP 调整 +``` + +## 实现注意事项 + +- **S1 字段待确认**:XPay 官方退款查询/平台字段未联调确认,新增字段与状态枚举处必须保留「待确认」注释,不臆造字段名;未通过反查一律 `OrderRealityCheckFailed(70009)` + `ErrCode:1` 触发微信重试。 +- **M1 防误回收**:「已取消但已付款」的订单权益从未发放,`settleRefundSuccess` 对该场景只做 `Cancelled->Refunded` 流转与退款信息记录,**跳过 `revokeOrderBenefits`**,避免误扣用户其他来源的会员天数/额度凭证。 +- **M2 保持 Trial**:`ensureTrialVIP` 复用 `VIPGrantIdempotent` 传 `Trial(4)`,SQL 推导时须保留 Trial 不参与年度/月度推导。 +- **M2 SQL 求值顺序**:依赖 MySQL 单表 UPDATE SET 从左到右求值,先计算 `vip_expire` 再推导 `vip_type`,与现有「显式控制 SET 顺序」注释保持一致。 +- **M4 不信任前端**:platform 的资损判断改为以微信侧反查结果为准,保留 env 校验。 +- **M12 余量**:`MaxBytesReader` 上限按 `MaxSizeMB*1024*1024 + 1MB`(multipart 边界开销),超限返回上传超限语义错误,避免误伤合法请求。 +- **安全**:广告激励金额服务端定价后,不再接收客户端金额;退款反查沿 HMAC 签名服务端 API,无法被伪造。 +- **验证**:全部改动完成后 `go build ./...` 与 `go vet ./...` 通过,并核对 linter。 + +## 推荐扩展 + +### Skill + +- **lsp-code-analysis** +- 用途:追踪 `settleRefundSuccess`、`VIPGrantIdempotent`、`QuotaGrant`、`verifyXPayOrderPaid` 等关键符号的定义与全部调用点,确认改动影响面,避免遗漏调用链或误改 Trial 场景。 +- 预期结果:明确各修复点的完整调用链,验证「Cancelled 订单跳过权益回退」「Trial 保持」等分支覆盖所有相关调用方。 + +### SubAgent + +- **code-explorer** +- 用途:跨目录核对涉及文件与调用点,补充确认 `errcode` 分段、`QuotaGrantDao` 查询能力、`RefundFlowLog` 字段等实现细节。 +- 预期结果:产出修复所需的精确文件与函数清单,支撑分组实施。 \ No newline at end of file diff --git a/.codebuddy/plans/修复全部_39_项代码缺陷_f3b9019e.md b/.codebuddy/plans/修复全部_39_项代码缺陷_f3b9019e.md new file mode 100644 index 0000000..6575e45 --- /dev/null +++ b/.codebuddy/plans/修复全部_39_项代码缺陷_f3b9019e.md @@ -0,0 +1,228 @@ +--- +name: 修复全部 39 项代码缺陷 +overview: 根据代码审查报告,修复全部 39 项业务逻辑与技术逻辑 bug。核心是资损链路(退款回退权益、取消后付款自动退款、权益发放幂等化)、安全修复(密钥环境变量化、JWT 校验、错误码映射)与正确性修复(VIP 续费 CASE 求值顺序、Redis 超时单位、配额唯一约束等)。鉴权降级按用户决策维持 fail-open 现状,不改代码。 +todos: + - id: fix-dal + content: 修复数据访问层:VIP 续费 CASE 求值顺序、QuotaAddTotal 命中检查、新增 QuotaReduceTotal/UserReduceVIP、配额与用户唯一索引 tag + status: completed + - id: fix-redis + content: 修复 Redis 数据层:点赞/收藏未命中回源语义、计数 TTL、未读数对账、token 集合 TTL、清理 QuotaRemain 死缓存 + status: completed + - id: fix-benefit + content: 修复权益发放幂等化:发放与流水同事务、订单快照、Paid 后凭证恢复、retry_count 原子自增、唯一冲突区分 + status: completed + dependencies: + - fix-dal + - id: fix-refund + content: 修复退款链路:退款成功回退权益、取消后付款自动微信退款、退款申请与流水幂等、grantQuota 并发建行与流水失真 + status: completed + dependencies: + - fix-dal + - id: fix-business + content: 修复其他业务逻辑:登录并发与封禁、点赞通知去重、穿搭公开锁、衣物回退竞态、衣柜分享明细、通知已读与 AI 锁错误 + status: completed + dependencies: + - fix-redis + - id: fix-infra + content: 修复基础设施与安全:Redis 超时单位、密钥环境变量化、CORS、错误码映射、端口绑定、优雅关闭、panic 恢复、JWT 校验、COS 与日志等 + status: completed + - id: fix-dto-migrate-verify + content: 补充 DTO 校验、新增唯一索引迁移 SQL,并执行 go build 与 go vet 验证全部修复 + status: completed + dependencies: + - fix-dal + - fix-redis + - fix-benefit + - fix-refund + - fix-business + - fix-infra +--- + +## 用户需求 + +修复此前全量代码审查发现的 **39 项 bug**(业务逻辑 15 项 + 技术逻辑 24 项),不引入新的架构模式,保持现有 Gin + GORM + Redis 分层风格,修复后项目可正常编译。 + +## 关键决策(已确认) + +1. **修复范围**:全部 39 项(含低危的边界校验、日志、代码质量问题)。 +2. **退款权益**:订单全额退款成功后,**回退已发放的会员天数与各类额度**。 + +- 额度回退:仅回退未使用部分,已用部分不回退(`total = max(used, total - n)`)。 +- VIP 回退:扣减会员天数,但到期时间不倒退到过去(`vip_expire = max(now, vip_expire - n*86400)`)。 + +3. **取消后付款**:订单本地已取消但用户实际已付款时,**自动发起微信退款**(调用已存在的 `wechat.Refund`)。 +4. **鉴权降级**:Redis 故障时 access token 会话校验**维持 fail-open 现状,不改代码**(仅在注释中记录该决策)。 + +## 修复原则 + +- 优先修复资损与安全高危项(退款回退、权益重复发放、自动退款、密钥、JWT)。 +- 幂等性修复统一采用「唯一索引兜底 + 流水先写 + 累加同事务」的成熟模式。 +- 每个模块的修复保持小步、可编译,不跨模块做无关重构。 +- 新增数据库迁移 SQL 放入 `migrations/`,不动已有迁移文件。 + +## 技术栈 + +- Go 1.27、Gin v1.12、GORM v1.31(MySQL + soft_delete 插件)、go-redis v9、golang-jwt/v5、OpenTelemetry、微信支付 apiv3(`wechatpay-go` 官方 SDK)、腾讯云 COS、viper。 + +## 实现方法 + +采用「分层分模块」修复策略,自底向上:数据访问层 → 资损链路 → 业务逻辑 → 基础设施 → DTO 校验与迁移。核心思路: + +### 1. 退款回退权益(A1) + +- 新增 `QuotaDao.QuotaReduceTotal`:`UPDATE user_quotas SET total = GREATEST(used, total - ?) WHERE user_id = ? AND quota_type = ?`,检查 `RowsAffected` 防静默丢失。 +- 新增 `UserDao.UserReduceVIP`:`UPDATE users SET vip_expire = GREATEST(?, vip_expire - ?) WHERE id = ?`(now, days*86400)。 +- 幂等:仅在退款回调 `SUCCESS` 且流水从非成功态流转到成功态时执行回退;回退写 `QuotaFlowLog`/`VIPFlowLog`,新增 `ChangeType` 常量(退款回收),并以 `order_no + 回收类型` 唯一索引兜底防重复回退。 + +### 2. 取消后付款自动退款(A2) + +- `PayNotify` 的 `OrderStatusCancelled` 分支:生成 `refundNo` → 创建 `RefundFlowLog(status=Processing)` → 调用 `wechat.Refund(orderNo, refundNo, payAmount, payAmount, reason)` → 记录微信退款单号。 +- 幂等:创建退款流水前按 `order_no` 查重(已有非失败退款则跳过),并为 `refund_flow_logs` 增加 `order_no` 唯一约束(部分退款场景暂不支持,本项目为全额退款)。 + +### 3. 权益发放幂等化(A3/A4/A6/B11/B12) + +- 将「不可逆累加」与「幂等凭证写入」放入**同一 DB 事务**: +- VIP:事务内先写 `VIPFlowLog`(唯一索引 `order_no + change_type`),冲突即视为已发放;成功后再 `UserRenewVIPAtomic`。 +- 额度:事务内先写 `QuotaFlowLog`(唯一索引 `order_no + quota_type + change_type`),冲突即视为已发放;成功后再 `QuotaAddTotal`。 +- `grantOrderBenefits` 改用**订单快照字段**(`order.MemberDays`)而非实时读 `goods`(A5);订单需在快照中补充各类额度字段或从 `benefit_grant_tasks` 的 `Amount` 取值。 +- 崩溃恢复(A6):`PayNotify` 的 `OrderStatusPaid` 幂等分支不再直接 return,而是检查该订单是否已存在 `benefit_grant_tasks` 凭证,若无则补建并发起发放(幂等)。 +- `retry_count` 改为 `retry_count = retry_count + 1` 原子自增(B11)。 +- `grantBenefitIdempotent` 用错误类型区分「唯一键冲突」与「其他 DB 错误」(B12)。 + +### 4. VIP 续费 CASE 修复(B2) + +- `UserRenewVIPAtomic` 改为原生 SQL(`Exec`)显式控制 SET 顺序,使 `vip_start` 先于 `vip_expire` 求值,读到旧 `vip_expire`,恢复「过期续费重置 vip_start」语义。 + +### 5. 配额与 Redis 修复(A7/B5/B6/B7/B13/B29/B30/B31) + +- `QuotaAddTotal` 补 `RowsAffected` 检查;`user_quotas` 模型加 `(user_id, quota_type)` 唯一索引 tag,并新增迁移 SQL。 +- `grantQuota` 创建分支改为「先插入(唯一索引冲突则重查后走累加)」;流水 `oldValue/newValue` 改为在原子更新后回读真实值写入。 +- 点赞/收藏 `SIsMember` 在 key 不存在时返回 `redis.Nil` 语义(或 DAO 返回 `(false, redis.Nil)`),使 service 层正确回落 MySQL。 +- 访问计数 `INCR` 配套首次设置 TTL;未读数 key 改用 DB 对账回源;token 集合 TTL 只在首次 SAdd 时设置;删除或补全只写不读的 `QuotaRemain`。 + +### 6. 基础设施与安全(B3/B4/B8/B9/B14-B19/B22-B28/B36) + +- Redis 超时统一单位:代码改 `* time.Second`,与配置注释「单位s」对齐(B3)。 +- 密钥环境变量化:`setting` 增加 `AutomaticEnv` + `SetEnvKeyReplacer`;`config.yaml` 敏感值改为占位/移除,保留 `config.example.yaml` 模板(B4/B25)。 +- CORS 收窄来源白名单(B8);错误码补充 `Forbidden→403`、`TooManyRequests→429`、`ClassifyFailed→500` 等映射(B9/B24)。 +- `ListenAndServe` 立即错误触发退出(B14);优雅关闭拆分 HTTP 与 OTel context(B15);COS nil 保护(B16);4 个后台 goroutine 加 `defer recover()`(B17);配置段 nil 校验(B18);JWT 校验 issuer 且拒绝空密钥(B19);日志目录 `MkdirAll`(B23);`url.Parse` 错误处理(B22);OTel 失败回滚全局 provider(B26);清理重复 `UserDefaultQuota` 配置段(B27);时间单位避免 duration 双重乘算(B28);访问日志 query 脱敏(B36)。 + +### 7. 其余业务逻辑(A8-A13/B10/B20/B35/B37/B38/B39) + +- 登录并发注册:`wx_open_id` 唯一索引 + duplicate 冲突后重查继续登录;登录拦截临时/永久封禁(A8/A13)。 +- 点赞通知去重(A9);穿搭公开加分布式锁 + 条件更新(A10);衣物删除回退改原子判定(A11);衣柜分享明细失败返回错误(A12)。 +- 刷新令牌强制携带旧 access token 并吊销(B10);通知已读条件更新防重复扣减(B20);登录与鉴权降级策略统一为 fail-open 并注释(B35);AI 锁错误区分处理(B37);穿搭明细更新包事务(B38);通知未读数递增错误处理(B39)。 + +## 架构设计 + +保持现有分层(routers → service → mysqldao/redisdao → mysqlmodel/redismodel),不新增架构层。核心修复后的资金/权益流转如下: + +```mermaid +flowchart TD + A[微信支付回调 PayNotify] --> B{订单状态} + B -->|Pending| C[条件更新 Pending->Paid] + C --> D[创建权益发放凭证 benefit_grant_tasks] + D --> E[事务: 写流水唯一索引 + 累加权益] + E --> F[权益到账] + B -->|Cancelled| G[创建退款流水 + 调 wechat.Refund] + G --> H[退款回调 RefundNotify] + B -->|Paid 但无凭证| D + H -->|SUCCESS| I[事务: 回退额度 GREATEST + 回退VIP天数] + I --> J[写回退流水 幂等防重] + J --> K[订单置 Refunded] +``` + +## 目录结构 + +以下为本次修复涉及的文件(MODIFY 表示修改,NEW 表示新增): + +``` +/root/zt-closet-backend +├── main.go # [MODIFY] B14/B15/B16/B17/B18/B23/B28 端口绑定/优雅关闭/panic恢复/配置nil/日志目录/时间单位 +├── configs/ +│ ├── config.yaml # [MODIFY] B3/B4/B27 超时单位、密钥占位、清理重复段 +│ └── config.example.yaml # [MODIFY] 同步模板更新 +├── global/ # 视 B18 需要调整设置读取 +├── migrations/ +│ └── 20260907_bugfix_constraints.sql # [NEW] 唯一索引:user_quotas、quota_flow_logs、vip_flow_logs、refund_flow_logs、users.wx_open_id +├── internal/ +│ ├── mysqlmodel/ +│ │ ├── quota.go # [MODIFY] A7 唯一索引 tag +│ │ ├── user.go # [MODIFY] A8 wx_open_id 唯一索引 tag +│ │ ├── quotaflowlog.go # [MODIFY] A4 唯一索引 tag +│ │ ├── vipflowlog.go # [MODIFY] A3 唯一索引 tag +│ │ └── refundflowlog.go # [MODIFY] A14 唯一索引 tag +│ ├── mysqldao/ +│ │ ├── user.go # [MODIFY] B2 VIP CASE、新增 UserReduceVIP +│ │ └── quota.go # [MODIFY] B5/A7、新增 QuotaReduceTotal +│ ├── redisdao/ +│ │ ├── like.go / collect.go # [MODIFY] B6 未命中语义 +│ │ ├── closet.go / outfit.go # [MODIFY] B7 计数 TTL +│ │ ├── quota.go # [MODIFY] B29 死缓存清理 +│ │ ├── notification.go # [MODIFY] B30 未读数对账 +│ │ └── auth.go # [MODIFY] B31 token 集合 TTL +│ ├── redismodel/ +│ │ └── keys.go # [MODIFY] B3 超时单位 +│ ├── service/ +│ │ ├── order.go # [MODIFY] A1/A2/A5/A6/A14/A15/B12/B21 退款回退/自动退款/快照/退款幂等 +│ │ ├── benefit_compensation.go # [MODIFY] A4/B11 幂等发放/retry_count +│ │ ├── service.go # [MODIFY] A7/B13 grantQuota 并发与流水 +│ │ ├── constants.go # [MODIFY] 新增退款回收 ChangeType 常量 +│ │ ├── user.go # [MODIFY] A8/A13/B10/B35 登录/封禁/刷新/降级 +│ │ ├── like.go # [MODIFY] A9 点赞通知去重 +│ │ ├── outfit.go # [MODIFY] A10/B38 公开锁/明细事务 +│ │ ├── clothes.go # [MODIFY] A11 删除回退竞态 +│ │ ├── closet.go # [MODIFY] A12 明细失败处理 +│ │ ├── notification.go # [MODIFY] B20/B39 已读幂等/未读数 +│ │ └── airecommend.go # [MODIFY] B37 锁错误处理 +│ ├── dto/ +│ │ ├── closet.go # [MODIFY] B32 过期时间校验 +│ │ ├── user.go # [MODIFY] B33 binding tag +│ │ └── outfit.go / clothes.go # [MODIFY] B34 文本长度上限 +│ └── middleware/ +│ ├── cors.go # [MODIFY] B8 来源白名单 +│ ├── logger.go # [MODIFY] B36 query 脱敏 +│ └── lzcwyjwt.go # [MODIFY] B1 仅注释说明 fail-open 决策 +├── pkg/ +│ ├── setting/setting.go # [MODIFY] B25 环境变量支持 +│ ├── lzcwyjwt/lzcwyjwt.go # [MODIFY] B19 issuer/密钥校验 +│ ├── errcode/errcode.go # [MODIFY] B9/B24 状态码映射 +│ ├── cos/cosclient.go # [MODIFY] B22 url.Parse 错误 +│ ├── tracing/provider.go # [MODIFY] B26 失败回滚 +│ └── upload/upload.go # [MODIFY] B16 COS nil 保护 +└── pkg/tokenhub/scheduler.go / fortune.go # [MODIFY] B17 panic 恢复 +``` + +## 关键代码结构 + +新增 DAO 方法(接口级定义,实现时遵循现有 DAO 风格): + +``` +// QuotaReduceTotal 原子扣减总配额(退款回收),保证 total 不低于 used,返回是否命中。 +func (d *QuotaDao) QuotaReduceTotal(ctx context.Context, userID int64, quotaType int8, n int64) (bool, error) + +// UserReduceVIP 原子回退会员到期时间,保证不早于 now,返回是否命中。 +func (d *UserDao) UserReduceVIP(ctx context.Context, userID int64, now int64, memberDays int64) (bool, error) +``` + +## 实施注意事项 + +- **兼容性**:新增唯一索引前需评估存量数据是否已有重复(`user_quotas`、`quota_flow_logs`、`vip_flow_logs`、`refund_flow_logs`),迁移 SQL 需先清理/合并重复再建索引。 +- **事务边界**:GORM 事务使用 `db.Transaction(func(tx *gorm.DB) error {...})`,避免跨 service 的裸事务。 +- **回退幂等**:退款回退与权益发放的所有「写流水 + 累加/回退」必须在同一事务内,唯一索引冲突通过错误类型识别并视为已处理。 +- **日志与安全**:环境变量化后,禁止在日志打印 openid/金额/密钥;`config.yaml` 敏感值改为 `${ENV_VAR}` 占位。 +- **验证**:修复完成后执行 `go build ./...` 与 `go vet ./...`,确保可编译且无新增告警;不强制写测试,但不破坏现有逻辑。 + +## 使用的扩展 + +### SubAgent + +- **code-explorer** +- 用途:在执行各模块修复前,快速确认相关文件的完整实现、字段定义与调用点(如 `grantQuota`/`doGrantBenefit`/`RefundNotify` 的全部调用方),确保修改不遗漏、不引入冲突。 +- 预期产出:每个修复模块的精确修改点清单与调用关系,支撑小步、可编译的改动。 + +### Skill + +- **lsp-code-analysis** +- 用途:修复后对关键函数(`consumeQuota`、`grantQuota`、`doGrantBenefit`、`UserRenewVIPAtomic`、`QuotaReduceTotal`、`UserReduceVIP`)做定义/引用/调用层次追踪,验证幂等与回退链路没有遗漏调用点或破坏现有引用。 +- 预期产出:关键函数完整调用链,确认修复后无断链、无重复发放/回退缺口。 \ No newline at end of file diff --git a/.codebuddy/plans/修复分类模型手表识别错误_0588c8ee.md b/.codebuddy/plans/修复分类模型手表识别错误_0588c8ee.md new file mode 100644 index 0000000..13dac02 --- /dev/null +++ b/.codebuddy/plans/修复分类模型手表识别错误_0588c8ee.md @@ -0,0 +1,83 @@ +--- +name: 修复分类模型手表识别错误 +overview: 优化 Fashion-CLIP 分类服务的零样本提示词(大类「配饰」覆盖手表、强化「手表」子类别特征、纠正与「鞋/手链」的混淆),重启分类服务并用手表测试图验证识别结果。 +todos: + - id: tune-prompts + content: 优化 main.py 大类「配饰/鞋」与配饰组「手表/手链」提示词 + status: completed + - id: restart-service + content: 重启分类服务进程并轮询健康检查直至恢复 + status: completed + dependencies: + - tune-prompts + - id: verify-regression + content: 用测试图验证手表识别并回归验证正常衣物分类不受影响 + status: completed + dependencies: + - restart-service + - id: iterate-archive + content: 按验证结果迭代微调提示词并归档测试图到 resources + status: completed + dependencies: + - verify-regression +--- + +## 产品概述 + +修复衣柜小程序的衣物分类模型无法识别「手表」的问题。实测复现:上传手表图片后,分类服务将手表误识别为「鞋」大类(置信度 0.9471),正确大类「配饰」仅 0.0013,导致子类别轮不到「手表」。 + +## 核心功能 + +- 手表图片分类结果正确:大类输出「配饰」、子类别输出「手表」 +- 正常衣物图片分类结果不受影响(回归基线:上装/衬衫) +- 分类服务重启后健康检查恢复,上传→抠图→分类链路保持正常 + +## 技术栈 + +- 分类服务:Python FastAPI + transformers + patrickjohncyh/fashion-clip(零样本 CLIP 分类,端口 8000) +- 修改范围:仅 `thirdparty/classification_server/main.py` 的提示词字典,不改推理逻辑、响应结构、后端 Go 代码 + +## 实现方案 + +采用零样本 CLIP 提示词工程修复,三处针对性调整: + +1. **大类「配饰」提示词**(`CATEGORY_PROMPTS`,第 47 行):加入 watch/wristwatch 描述,强化「小而独立的腕部配饰、非衣物主体」语义,补齐手表完全未覆盖的问题。 +2. **大类「鞋」提示词**(第 46 行):补充强视觉特征(成双出现 pair、鞋底 sole、包裹脚的立体鞋型),压低手表白底商品图与「鞋」的相似度;微调幅度保守,避免影响正常鞋类识别。 +3. **「配饰」组内子类别提示词**(`SUBCATEGORY_GROUPS["配饰"]`,第 115、122 行): + +- 「手表」强化表盘特征:round watch face、circular dial with numerals and hands、crown、metal or leather strap; +- 「手链」显式排除表盘:no watch face、no dial、purely decorative,消除两者互选错误。 + +关键决策: + +- 只改提示词字符串,每组 label 数量不变,单次前向 batch 规模不变,推理性能无变化; +- 响应 JSON 字段结构不变,`pkg/classification/classification.go` 与 `internal/service/clothes.go` 无需改动,完全向后兼容; +- 大类修复与子类别修复同步完成,一次生效,避免「大类对了但子类错」的二次返工。 + +## 实施细节 + +### 服务重启流程(已实测进程信息) + +- 当前分类服务 PID 3156827,cwd=/root/zt-closet-backend,启动命令 `thirdparty/matting_server/.venv/bin/python thirdparty/classification_server/main.py`(复用 matting_server 的 .venv,无 systemd 托管;模型在模块 import 时加载,改代码必须重启)。 +- 重启方式:kill 3156827 后,在项目根目录按原解释器与原命令用 nohup 拉起,日志重定向到 `storage/logs/classification.log`;轮询 `curl http://127.0.0.1:8000/healthz` 直至返回 ok(模型重载约 1 分钟)。 +- 严禁误动抠图服务 PID 3144600(cwd=thirdparty/matting_server)。 +- 重启窗口内后端有 retryCall(MaxRetry=3)兜底,短暂不可用影响可控。 + +### 验证与回归 + +- 手表测试图 `/root/zt-closet-backend/0d98288268054879d481a7bb1d2dd7af.jpg`:期望 category_main=配饰、category_sub=手表; +- 回归图 `resources/微信图片_20260907235100_780_53.jpg`:期望维持 category_main=上装、category_sub=衬衫(当前基线 0.5028/0.5024); +- 若验证不达标,迭代微调提示词措辞(最多 2~3 轮),每轮重启后重测。 + +### 目录结构 + +``` +/root/zt-closet-backend/ +├── thirdparty/classification_server/ +│ └── main.py # [MODIFY] 修改 CATEGORY_PROMPTS 的「配饰」「鞋」提示词,及 SUBCATEGORY_GROUPS["配饰"] 的「手表」「手链」提示词 +├── storage/logs/classification.log # [NEW] 重启后的分类服务日志(nohup 重定向) +├── 0d98288268054879d481a7bb1d2dd7af.jpg # [MOVE] 验证通过后移入 resources/ 留作回归样本 +└── resources/ + ├── 微信图片_20260907235100_780_53.jpg # [不变] 回归验证用正常衣物图 + └── 0d98288268054879d481a7bb1d2dd7af.jpg # [NEW] 归档的手表测试图 +``` \ No newline at end of file diff --git a/.codebuddy/plans/修复支付业务逻辑问题_cb2e4aae.md b/.codebuddy/plans/修复支付业务逻辑问题_cb2e4aae.md new file mode 100644 index 0000000..65c5335 --- /dev/null +++ b/.codebuddy/plans/修复支付业务逻辑问题_cb2e4aae.md @@ -0,0 +1,93 @@ +--- +name: 修复支付业务逻辑问题 +overview: 修复二次审查发现的支付业务问题:2 个资损级缺陷(虚拟支付订单取消漏洞、道具价格不同步)+ 5 个逻辑缺陷(iOS 最低价校验、退款状态判断、PaidTime 兜底等)+ 2 处文档同步,全部在现有支付代码上做定点修复。 +todos: + - id: fix-p0-1 + content: 修复取消漏洞:新增 70007 错误码、Cancel 拒绝虚拟支付订单、handleGoodsDeliver 补 Cancelled 补发货分支 + status: completed + - id: fix-p0-2 + content: 修复道具价格不同步:GoodsListVirtualSync 改为全量查询上架商品并更新同步注释与运营约定 + status: completed + - id: fix-p1 + content: 修复 P1 缺陷:新增 70008 与 iOS 最低金额校验、退款推送状态判断、PaidTime 兜底、策略注释 + status: completed + - id: sync-p2 + content: 同步 P2:OrderResponse 加 pay_channel 字段与映射,接口文档补退款路径、错误码与道具刷新说明 + status: completed + - id: verify + content: 执行 go build、go vet、go test 与 read_lints 全量验证 + status: completed + dependencies: + - fix-p0-1 + - fix-p0-2 + - fix-p1 + - sync-p2 +--- + +## 产品概述 + +对支付业务逻辑二次审查发现的 2 个资损级问题、5 个逻辑缺陷与 2 个文档问题做定点修复,不改变支付链路整体架构与权益发放回退幂等机制。 + +## 核心修复内容 + +### P0 资损级 + +- P0-1 虚拟支付订单取消漏洞:虚拟支付订单(pay_channel=2)不可再取消(新增 70007 错误码);发货推送补「已取消订单补发货」防御分支,杜绝「用户取消后继续支付、钱已扣但权益永不发放」。 +- P0-2 道具价格不同步:道具同步改为全量刷新全部上架商品(含已发布),每次启动重新上传发布道具信息与价格,保证微信侧道具价格与本地改价一致,避免金额校验失败导致付款不发货。 + +### P1 逻辑缺陷 + +- iOS 下单校验最低 1 元(100 分),低于此金额拒绝(70008)。 +- 退款推送仅「成功」状态才回退权益,防中间状态误回退。 +- 发货推送 PaidTime 缺失或异常时兜底为当前时间。 +- iOS 退款问询保持默认建议退款,补充策略注释。 + +### P2 文档与提示 + +- 订单响应增加 pay_channel 字段(1=微信支付 2=虚拟支付)。 +- 接口文档补充虚拟支付退款操作路径(虚拟支付后台 Midas)、道具价格刷新运营约定、新错误码说明。 + +## 技术方案 + +### 修复策略 + +定点修复,复用现有架构与幂等机制:不动 `benefit_grant_tasks` 发放凭证、唯一索引幂等、`settleRefundSuccess`/`revokeOrderBenefits` 回退链路;不新增表结构与迁移 SQL。 + +### 关键改动 + +**1. 错误码(pkg/errcode/order_code.go)** + +- 新增 `VirtualOrderCancelNotAllowed = NewError(70007, "虚拟支付订单不支持取消")` +- 新增 `VirtualPayAmountTooLow = NewError(70008, "iOS 虚拟支付最低 1 元")` +- 70007/70008 空闲无冲突(70005/70006 已占用)。 + +**2. 取消漏洞(internal/service/order.go + virtualpay.go)** + +- `Cancel`:`getOwnedOrder` 后加 `if order.PayChannel == PayChannelVirtual { return nil, errcode.VirtualOrderCancelNotAllowed }`(置于状态校验前,统一拒绝提示);同时避免对虚拟订单误调 JSAPI `CloseOrder`。 +- `handleGoodsDeliver`:switch 增加 `case OrderStatusCancelled:`,条件流转 `Cancelled → Paid` 成功后 `finalizeVirtualPaidOrder` 补发货,与 `Expired` 分支并列(防御历史数据与竞态)。 + +**3. 道具全量同步(internal/mysqldao/goods.go + virtualpay.go)** + +- `GoodsListVirtualSync` 查询条件改为 `Where("status = ?", 1)`,返回全部上架商品(含已发布)。 +- `SyncVirtualGoods`/`syncOneGoods` 注释更新为全量同步语义:重复 upload+publish 同 goods_id 视为更新道具信息与价格;商品 SKU 少(5 类),启动一次开销可忽略;失败仍置 `GoodsProductPublishFail`。 + +**4. P1 系列(internal/service/virtualpay.go)** + +- `VirtualOrderCreate`:goods 上架校验后、落单前加 `if request.Platform == XPayPlatformIOS && payAmount < 100` → 返回 70008。 +- `handleRefundNotify`:仅 `RefundStatus == ""` 或 `== "SUCCESS"` 时调用 `settleRefundSuccess`,其他状态 `slog.WarnContext` 记录并返回(枚举联调确认)。 +- `finalizeVirtualPaidOrder`:`paidTime <= 0` 时兜底 `nowUnix()`。 +- `handleIOSRefundQuery`:补充策略注释(默认建议退款、已用权益不回退、后续可按权益使用情况细化拒绝)。 + +**5. 文档与 DTO(internal/dto/order.go + service/order.go + 接口文档.md)** + +- `OrderResponse` 加 `PayChannel int8 json:"pay_channel"`;`buildOrderResponse` 加映射。 +- 接口文档:订单对象(4.5 节)补 pay_channel 字段;退款章节补「虚拟支付订单请在虚拟支付后台(Midas)发起退款」;虚拟支付下单章节补 70007/70008 说明与「改价后重启服务自动刷新道具价格」运营约定;错误码表补 70007/70008。 + +## 验证 + +- `go build ./...`、`go vet ./...`、`go test ./pkg/wechat/` 通过。 +- read_lints 检查改动文件无新增错误。 + +## 约束 + +- 不改支付架构、权益幂等机制、表结构;不修改 configs/config.yaml(本地文件,含非法 YAML)。 \ No newline at end of file diff --git a/.codebuddy/plans/允许免费额度配置为_0_dc363311.md b/.codebuddy/plans/允许免费额度配置为_0_dc363311.md new file mode 100644 index 0000000..251b373 --- /dev/null +++ b/.codebuddy/plans/允许免费额度配置为_0_dc363311.md @@ -0,0 +1,92 @@ +--- +name: 允许免费额度配置为 0 +overview: 修复新用户免费额度配置无法设为 0 的问题:配置字段改为 *int64 以区分「未配置」与「显式 0」,未配置回落内置默认,显式 0 生效为 0。 +todos: + - id: change-setting-fields + content: 将 UserDefaultQuotaSettingS 四字段改为 *int64 并更新注释 + status: completed + - id: fix-default-quota-value + content: 改 defaultQuotaValue 为指针判空判据,允许 0 生效 + status: completed + dependencies: + - change-setting-fields + - id: sync-constants-comment + content: 同步 constants.go 内置默认常量注释语义 + status: completed + dependencies: + - fix-default-quota-value + - id: sync-docs + content: 同步 README、config.example.yaml 与接口文档说明 + status: completed + dependencies: + - fix-default-quota-value + - id: build-verify + content: 执行 go build ./... 与 go vet ./... 验证编译 + status: completed + dependencies: + - sync-constants-comment + - sync-docs + - id: review-config-usage + content: 用 [subagent:code-explorer] 复核配置消费点与文档无旧语义残留 + status: completed + dependencies: + - build-verify +--- + +## 产品概述 + +修复新用户免费额度初始化的配置语义缺陷:原有实现把「配置为 0」误判为「未配置」而回落内置默认,导致无法将某类免费额度配成 0(例如要求新用户 AI 推荐额度为 0、必须购买后才可用)。 + +## 核心功能 + +- **区分「未配置」与「显式 0」**:`UserDefaultQuota` 四个配置字段改为指针类型,未配置(nil)回落内置默认 20/3/10/5,显式配置 0 则生效为 0 +- **保持兜底能力**:整段配置缺失、单个键缺失、配置为负数三种情况仍回落内置默认,不破坏既有部署 +- **文档同步**:README 配置项说明、config.example.yaml 注释、接口文档额度说明统一为「允许配 0,未配置回落默认」 +- **行为不变**:四类额度的初始化流程、流水记录、唯一索引幂等逻辑完全不动 + +## 技术栈 + +- Go 1.27,模块 `git.lzcwy.cn/zhengchiliu/zt-closet-backend` +- 配置:`pkg/setting`(viper 只读 `configs/config.yaml`,`ReadSection` 走 `UnmarshalKey`/mapstructure) +- 消费点唯一:`internal/service/quota.go` 的 `defaultQuotaValue`(grep 已确认无其它引用) + +## 实现方案 + +采用**指针字段区分三态**:把 `UserDefaultQuotaSettingS` 的 `Cos/AI/OutfitShare/ClosetShare` 由 `int64` 改为 `*int64`。viper 解码时键存在则分配指针(含 0),键不存在则保持 nil,从而在类型层面区分「未配置」与「配置为 0」。 + +取值判据(`defaultQuotaValue`): + +- `cfg == nil`(整段缺失)→ 内置默认 +- `cfg.X == nil`(该键缺失)→ 内置默认 +- `*cfg.X >= 0` → 用配置值(**0 生效,本次修复目标**) +- `*cfg.X < 0` → 内置默认(防御:负数会让 `total` 为负,破坏额度校验) + +关键决策与取舍: + +1. **用 `*int64` 而非把判据改成 `>= 0`**:后者能让 0 生效,但「键缺失」解码后同样是 0,会静默把兜底默认值变成 0——对现有未显式配置的环境是破坏性变更。指针方案零歧义,代价仅是多一层判空。 +2. **用指针而非 `viper.IsSet` 逐键查询**:配置结构仍由结构体承载,`ReadSection` 用法与其它配置段一致,不引入第二套读取方式,符合现有架构。 +3. **负数回落而非报错**:保持 best-effort 风格(与额度初始化失败仅告警不阻断一致),不引入启动期配置校验的新失败路径。 +4. **保留内置常量值 20/3/10/5 不变**:只改判据与注释,不动 `ensureDefaultQuota` 的创建+流水事务逻辑。 + +性能与可靠性:该路径仅在首次登录注册时执行 4 次,指针判空为 O(1) 无额外开销;风险点仅为指针解引用,通过判空分支覆盖,并有 `go build`/`go vet` 兜底。 + +## 实现要点(防回归) + +- 改字段类型后必须全量编译,`global.UserDefaultQuotaSetting` 的其它消费点若有解引用会直接编译报错(当前仅 quota.go 一处)。 +- 判空顺序必须为 `cfg != nil && cfg.X != nil && *cfg.X >= 0`,避免 nil 解引用 panic。 +- 注释需同步:结构体注释「值 <=0 视为未设置」、`constants.go:42`「配置缺省或 <=0 时回落」、`defaultQuotaValue` 函数注释,三处都要改成新语义,否则后续维护者会按旧语义改回去。 +- 不要扩大范围:`Upload.MaxSizeMB`、`MattingModel.MaxRetry` 等其它 `<=0` 兜底语义不同(0 无业务意义),本次不动,仅在结论中提示。 +- 不改动 `configs/config.yaml`(用户本地文件,已被 .gitignore 忽略)。 + +## 目录结构 + +``` +pkg/setting/section.go # [MODIFY] UserDefaultQuotaSettingS 四字段 int64 → *int64; + # 注释改为「nil=未配置回落默认;非 nil 生效,允许 0」 +internal/service/quota.go # [MODIFY] defaultQuotaValue 判据改为指针判空 + >=0 生效; + # 函数注释同步(未配置/负数回落,0 生效) +internal/service/constants.go # [MODIFY] 第 42 行注释「配置缺省或 <=0 时回落」→「未配置(nil)或负数时回落」 +README.md # [MODIFY] 第 195 行配置表说明改为「未配置回落内置默认 20/3/10/5;可显式配 0」 +configs/config.example.yaml # [MODIFY] UserDefaultQuota 段注释补充「可为 0 表示不赠送;留空则回落内置默认」 +接口文档.md # [MODIFY] 第 488/1432/1446 行及 5.2 节额度表格补「可由 UserDefaultQuota 配置,允许为 0」 +``` \ No newline at end of file diff --git a/.codebuddy/plans/全量代码二次审查报告_ceea1a15.md b/.codebuddy/plans/全量代码二次审查报告_ceea1a15.md new file mode 100644 index 0000000..d59d5da --- /dev/null +++ b/.codebuddy/plans/全量代码二次审查报告_ceea1a15.md @@ -0,0 +1,94 @@ +--- +name: 全量代码二次审查报告 +overview: 对 zt-closet-backend 全量 Go 代码进行二次审查(所有模块覆盖 + 支付/虚拟支付链路深挖),输出按严重程度分级的业务逻辑/技术逻辑问题清单报告,不修改任何代码。 +todos: + - id: explore-payment-chain + content: Use [subagent:code-explorer] to explore full codebase and read all payment-chain files (virtualpay, pay, order, benefit, quota, migration) + status: completed + - id: review-xpay + content: "Review virtual pay XPay chain: signing, notify push, goods sync, delivery/refund/iOS query handlers, session_key dependency" + status: completed + dependencies: + - explore-payment-chain + - id: review-wxpay-order + content: "Use [skill:lsp-code-analysis] to review WeChat Pay V3 and order state machine: callbacks, cancel, expire scan, race fallback" + status: completed + dependencies: + - explore-payment-chain + - id: review-benefit-quota + content: "Review benefit grant/revoke and quota flows: idempotency ledgers, compensation scheduler, transaction boundaries" + status: completed + dependencies: + - explore-payment-chain + - id: review-other-modules + content: "Review remaining modules and infrastructure: auth, clothes AI pipeline, community, middleware, DAO, security" + status: completed + dependencies: + - explore-payment-chain + - id: write-report + content: Compile severity-graded issue list and write Chinese review report to docs/ directory + status: completed + dependencies: + - review-xpay + - review-wxpay-order + - review-benefit-quota + - review-other-modules +--- + +## 产品概述 + +对智能衣橱微信小程序后端(Go + Gin)进行第二轮全量代码审查("二次 check"),系统性分析业务逻辑问题与技术逻辑问题。本次审查**不修改任何代码**,仅产出问题清单报告。 + +## 核心需求 + +- **审查范围**:全量代码(约 9700+ 行核心业务代码),并对支付链路做最深审查——包括最近新增的虚拟支付 XPay(`pkg/wechat/virtualpay.go`、`internal/service/virtualpay.go`)、普通微信支付 V3、订单状态机、权益发放/退款/补偿链路;其余模块(用户/衣物/穿搭/社区/分享/AI/运势/通知/中间件/DAO/迁移)全部过一遍 +- **审查维度**: +- 业务逻辑:状态机流转完整性、并发竞态(回调并发/幂等锁)、资损风险(金额校验、重复发放/回收、环境串单、防篡改校验)、权益一致性、越权、边界条件 +- 技术逻辑:并发安全(goroutine/锁)、事务边界、Redis/MySQL 一致性与降级、错误处理、资源泄漏、时间与金额精度、签名与验签正确性、HTTP 超时与重试、安全问题(SQL 注入/XSS/SSRF/密钥硬编码/权限) +- **复核历史**:对照此前 `.codebuddy/plans/` 中"修复支付业务逻辑问题"等计划的修复项,验证修复是否完备、是否引入新问题 +- **交付物**:一份中文审查报告(写入 `docs/` 目录 markdown 文件),按严重程度分级(严重/中等/轻微),每条问题包含:问题描述、位置(文件:行号)、触发场景与影响、修复建议;不修改任何业务代码 + +## 审查方法论 + +### 审查策略 + +1. **支付链路深挖**:逐行审查虚拟支付 XPay 新增代码与普通支付链路,重点核对: + +- 签名与验签(`pay_sig` 的 `uri+"&"+body` 规则、`signData` 字典序序列化、推送 URL SHA1 验签、V3 回调验签解密) +- 发货推送处理:幂等锁 30s、道具 ID/实付金额/环境三重防篡改校验、条件状态流转(待支付/已过期/已取消 → 已支付)的竞态兜底、`finalizeVirtualPaidOrder` 的 pay_time 兜底 +- 退款推送:`RefundStatus` 为空串时是否误回退权益、`settleRefundSuccess` 重复推送幂等 +- 道具同步:轮询仅 5 次×500ms 是否覆盖微信任务耗时、多商品循环重复 publish 同一 group、上传失败后 `product_status` 状态回填 +- `VirtualOrderCreate`:落单与签名的先后顺序、签名失败时遗留待支付订单、iOS 最低 1 元限制、session_key 依赖 Redis 的可用性 +- 取消/退款限制:虚拟支付订单禁取消、iOS 订单禁主动退款的位置与状态校验顺序 + +2. **订单/权益/额度链路**:状态机流转完整性、金额校验、幂等凭证唯一性、过期扫描竞态、补偿任务与 Redis 锁、事务边界(行锁+条件更新)、退款回收"已用额度不回退"规则 +3. **其余模块静态审查**:用户登录/鉴权、衣物上传与 AI 流水线、穿搭与社区、衣柜分享懒过期、AI 推荐扣额回退、运势生成锁、通知未读计数一致性 +4. **基础设施与安全**:中间件(JWT/限流/日志脱敏)、DAO 层 SQL 参数绑定、Redis 故障降级、配置读取、迁移脚本、错误码分段 + +### 工具与资源 + +- 使用 code-explorer 子代理做跨文件、跨目录的批量探索与调用链追踪 +- 使用 lsp-code-analysis 技能做语义级导航:定位订单/额度状态变更的所有写入点、回调处理函数的定义与引用、事务边界 +- 输出报告:新建 `docs/代码审查报告-二次check.md`,含分级问题清单与修复建议 + +### 分级标准 + +- **严重**:资损、安全漏洞、并发导致数据错乱/重复发放、状态机死锁或不可达 +- **中等**:边界条件缺失、一致性降级风险、性能隐患、错误处理不当 +- **轻微**:代码风格、冗余、注释与文档偏差 + +### 约束 + +- 全程只读,不修改任何业务代码;报告为唯一交付物 + +## 子代理 + +- **code-explorer** +- 用途:全量代码库跨目录探索,批量读取各模块核心文件,追踪支付/订单/权益链路的跨层调用(router → service → dao → pkg/wechat) +- 预期产出:完整的关键文件清单、调用链关系、各模块代码事实,支撑逐项问题分析 + +## 技能 + +- **lsp-code-analysis** +- 用途:对订单状态流转、额度变更、回调处理等关键符号做语义级定义/引用导航,确认所有状态写入点与并发路径 +- 预期产出:关键函数调用层次与状态变更写入点全集,避免漏查竞态路径 \ No newline at end of file diff --git a/.codebuddy/plans/全量代码审查_定位业务与技术逻辑_bug_6b069bbb.md b/.codebuddy/plans/全量代码审查_定位业务与技术逻辑_bug_6b069bbb.md new file mode 100644 index 0000000..fed652c --- /dev/null +++ b/.codebuddy/plans/全量代码审查_定位业务与技术逻辑_bug_6b069bbb.md @@ -0,0 +1,112 @@ +--- +name: 全量代码审查:定位业务与技术逻辑 bug +overview: 对项目全部 Go 源码(internal/ + pkg/ + global/ + main.go)做系统性代码审查,分模块定位业务逻辑与技术逻辑 bug,最终输出一份分类 bug 报告(含位置、原因、影响、严重程度),不做任何代码修改。 +todos: + - id: review-infra + content: 使用 [subagent:code-explorer] 审查 main.go、global/ 与 pkg/ 基础设施及第三方集成 + status: completed + - id: review-dal + content: 使用 [subagent:code-explorer] 审查 mysqlmodel、mysqldao、redismodel、redisdao 数据访问层 + status: completed + - id: review-money + content: 使用 [subagent:code-explorer] 审查订单/支付/权益/配额资损链路,并用 [skill:lsp-code-analysis] 追踪关键函数调用关系 + status: completed + dependencies: + - review-dal + - id: review-content + content: 使用 [subagent:code-explorer] 审查衣物/穿搭/收藏/点赞/通知/运势/AI 等业务逻辑 + status: completed + dependencies: + - review-dal + - id: review-auth + content: 使用 [subagent:code-explorer] 审查中间件、路由与鉴权(JWT、越权、参数绑定、安全) + status: completed + - id: write-report + content: 汇总各模块审查发现,输出分类中文 bug 报告(含位置、根因、影响、严重程度、建议) + status: completed + dependencies: + - review-infra + - review-dal + - review-money + - review-content + - review-auth +--- + +## 用户需求 + +对 `/root/zt-closet-backend` 智能衣橱后端服务进行**全量静态代码审查**,确认是否存在**业务逻辑 bug** 或**技术逻辑 bug**。 + +## 澄清结果 + +1. **交付方式**:仅输出一份分类的 **bug 报告**,**不做任何代码修改**。 +2. **审查范围**:全部 Go 源码(`internal/` + `pkg/` + `global/` + `main.go`)。 + +## 交付内容 + +一份中文 Markdown bug 报告,按「业务逻辑 bug」与「技术逻辑 bug」两大类组织,每条 bug 包含:文件路径与行号、问题描述、根因分析、影响、严重程度(高/中/低)、修复建议(仅建议,不实施)。报告需覆盖但不限于:订单支付退款、权益幂等发放、配额原子扣减与回退、并发与事务一致性、缓存一致性、鉴权与越权、金额/时间单位边界、错误处理与资源释放、安全(注入/越权/敏感信息泄露)等。 + +## 审查技术方案 + +### 审查方法 + +采用「分层 + 分域」静态审查策略,自底向上覆盖完整调用链: + +1. **基础设施层**(`main.go`、`global/`、`pkg/`):配置加载与初始化顺序、OTel/slog 初始化、MySQL/Redis/COS/微信支付客户端初始化、优雅关闭、goroutine 调度器、错误处理与资源释放。 +2. **数据访问层**(`internal/mysqlmodel/`、`internal/mysqldao/`、`internal/redismodel/`、`internal/redisdao/`):GORM 模型字段映射与索引/唯一约束、软删除、原子条件更新(`RowsAffected` 语义)、Redis key 设计与 TTL、分布式锁正确性(锁粒度、TTL、释放时 value 校验)。 +3. **业务服务层**(`internal/service/`):事务边界、并发一致性、幂等性、金额单位、时间戳单位、AuthZ 归属校验、缓存一致性、错误码映射、分页边界。 +4. **接口与鉴权层**(`internal/middleware/`、`internal/routers/`、`internal/dto/`):JWT 校验、会话降级策略、CORS、参数绑定、路由鉴权覆盖完整性。 + +### 审查重点清单 + +- **资损敏感链路**:订单创建(先微信下单后落库的竞态)、支付回调幂等与金额校验、退款回调、权益幂等发放、补偿任务重试、配额原子扣减/回退/发放。 +- **并发一致性**:分布式锁粒度与 TTL、锁释放误删风险、MySQL 条件更新的 `RowsAffected` 判断、多实例定时任务防重、`retry_count` 更新是否基于内存快照导致覆盖。 +- **幂等性**:支付回调、退款回调、权益发放、补偿任务崩溃恢复(`benefitAlreadyGranted` 流水查重)。 +- **边界与单位**:金额单位(分/元)、时间单位(秒/毫秒)、分页上限、切片越界/空指针、字符串截断(`genOrderNo`/`genRefundNo` 取 UUID 前 16 位)。 +- **安全**:SQL 注入(GORM 参数绑定)、越权访问(`getOwnedOrder` 等归属校验是否完整覆盖)、日志敏感信息(openid/金额)、JWT 会话 Redis 故障降级放行的安全权衡。 +- **资源与性能**:context 超时、goroutine 泄漏、Redis/DB 连接与 body 释放、N+1 查询、缓存穿透/击穿。 + +### 报告格式 + +最终输出一份中文 Markdown bug 报告,结构如下: + +- 「业务逻辑 bug」章节:业务规则、状态流转、权益/资损、幂等、越权相关缺陷。 +- 「技术逻辑 bug」章节:并发、事务、缓存、错误处理、资源管理、边界条件、安全相关缺陷。 +- 每条含:`文件:行号`、问题描述、根因、影响、严重程度(高/中/低)、修复建议。 + +严重程度定义: + +- **高**:资损、数据丢失、越权、严重并发不一致、安全漏洞。 +- **中**:功能错误、边界异常、异常处理缺失、缓存不一致。 +- **低**:代码质量、潜在风险、性能隐患、维护性问题。 + +### 审查覆盖范围 + +``` +/root/zt-closet-backend +├── main.go # 初始化与调度器启动 +├── global/ # 全局配置与连接(5 文件) +├── internal/ +│ ├── service/ # 业务编排(16 文件) +│ ├── mysqldao/ # MySQL DAO(14 文件) +│ ├── redisdao/ # Redis DAO 含锁(10 文件) +│ ├── mysqlmodel/ # GORM 模型(18 文件) +│ ├── redismodel/ # Redis 模型与 key(11 文件) +│ ├── dto/ # 请求/响应 DTO(13 文件) +│ ├── middleware/ # JWT/CORS/日志/OTel(5 文件) +│ └── routers/ # 路由与 handler(13 文件) +└── pkg/ # 工具与第三方封装(29 文件) +``` + +## 使用的扩展 + +### SubAgent + +- **code-explorer** +- 用途:对 `internal/`、`pkg/`、`global/`、`main.go` 等全部源码进行跨文件、跨目录的系统性读取与搜索,定位每个业务域的实现细节、调用关系与潜在缺陷。 +- 预期产出:各模块的审查发现清单(文件路径 + 行号 + 疑似问题描述),供汇总阶段整理为最终报告。 + +### Skill + +- **lsp-code-analysis** +- 用途:对资损敏感链路的几个关键函数(如 `consumeQuota`、`refundQuota`、`grantQuota`、`grantBenefitIdempotent`、`retryBenefitTask`、`OrderUpdateStatusByOrderNo`)做语义级定义/引用/调用层次追踪,验证并发、事务与幂等逻辑是否安全。 +- 预期产出:关键函数完整调用链与引用点,支撑并发/幂等结论的准确判断。 \ No newline at end of file diff --git a/.codebuddy/plans/删除衣物时清理COS图片_39034235.md b/.codebuddy/plans/删除衣物时清理COS图片_39034235.md new file mode 100644 index 0000000..bde396f --- /dev/null +++ b/.codebuddy/plans/删除衣物时清理COS图片_39034235.md @@ -0,0 +1,75 @@ +--- +name: 删除衣物时清理COS图片 +overview: 在衣物删除流程中,同步删除 COS 上对应的处理后图片对象,并对上传处理中的并发竞态做防护,避免删除衣物后 COS 图片残留。 +todos: + - id: add-cos-delete-util + content: 在 pkg/upload/upload.go 新增 FileDeleteCos 删除工具函数 + status: completed + - id: delete-cos-on-clothes-delete + content: 修改 DeleteByUUID,事务成功后删除 processed_image 对应 COS 对象 + status: completed + dependencies: + - add-cos-delete-util + - id: guard-async-upload-race + content: 修改 processImageAsync,上传后检测衣物已删除则清理 COS 对象 + status: completed + dependencies: + - add-cos-delete-util +--- + +## 产品概述 + +修复用户删除衣物后,腾讯云 COS 中对应抠图图片对象未被删除的问题。当前删除衣物只软删除了数据库记录,衣物抠图后上传到 COS 的图片仍残留在云端,产生无效存储占用。 + +## 核心功能 + +- 删除衣物时,若该衣物已有处理后的图片(`processed_image` 非空),同步删除 COS 上对应的图片对象。 +- COS 删除失败不影响衣物删除结果:用户删除请求始终成功返回,删除失败仅记录日志,便于后续排查与补偿。 +- 处理「上传处理中删除衣物」的竞态:用户在图片异步抠图/上传期间删除衣物时,异步流程在检测到衣物已被删除后,主动清理刚上传的 COS 对象,避免残留。 + +## 技术栈 + +- 语言与框架:Go + Gin + GORM(沿用现有项目技术栈) +- 对象存储:腾讯云 COS SDK `github.com/tencentyun/cos-go-sdk-v5 v0.7.75`(已存在) +- 复用现有:`global.CosClient`、`pkg/upload` 工具层、`slog` 日志、OpenTelemetry trace span + +## 实现方案 + +### 总体思路 + +在现有文件上传工具层新增一个 COS 删除工具函数,供删除流程与异步处理流程复用;在衣物删除事务提交成功后调用删除,删除失败仅记录日志不影响主流程;同时在异步图片处理成功回填前增加「衣物是否已被删除」的存在性检查,处理上传/删除并发竞态。 + +### 关键设计决策 + +- **删除失败不影响主流程**:COS 删除是尽力而为的清理操作。数据库软删除与额度回退仍以现有事务为准,COS 删除放在事务提交之后、并忽略错误返回,保证用户删除体验不因外部存储异常而失败。 +- **复用对象键语义**:`clothes.ProcessedImage` 字段存储的值即 COS 对象键(如 `{用户UUID}/{用户ID}/{随机名}.png`),上传时 `FileUploadCos(ctx, cosKey)` 正是以该值作为对象名上传,因此删除时直接用 `c.ProcessedImage` 作为 key 调用 `Object.Delete`。 +- **短超时防阻塞**:删除工具函数内部使用 10 秒超时 context,避免 COS 网络异常长时间阻塞删除接口。 +- **竞态防护位置**:在 `processImageAsync` 上传 COS 成功后、回填 `processed_image` 之前,通过 `ClothesGetByUUID` 检查衣物是否仍存在(软删除后普通查询会过滤已删行,`isNotFound` 为 true),若已删除则删除刚上传的对象并结束,避免回填丢失造成残留。 + +### 性能与可靠性 + +- COS Delete 为单次网络调用,时间复杂度 O(1);删除接口新增一次短超时调用,影响可控。 +- 上传/删除竞态窗口:检测存在性与删除 COS 之间仍可能存在极小窗口,但该窗口下后果仅为偶发残留单个对象,风险可控;核心场景(已处理完成再删除)被完整覆盖。 +- 不改动现有额度回退逻辑、错误码与软删除行为,保持向后兼容。 + +### 实现要点 + +- `pkg/upload/upload.go` 新增 `FileDeleteCos`,风格与 `FileUploadCos` 一致:nil client 保护、空 key 直接返回、trace span、10s 超时。 +- `internal/service/clothes.go` 的 `DeleteByUUID`:在事务闭包外声明变量承接 `c.ProcessedImage`,事务成功后非空则调用删除,失败用 `slog.WarnContext` 记录 `clothes_uuid` 与 `cos_key`,不记录密钥。 +- `internal/service/clothes.go` 的 `processImageAsync`:上传 COS 成功后、分类与回填前检查衣物是否存在;若已删除,删除刚上传对象并 `return`(额度已由删除事务回退,无需再处理)。 + +## 目录结构 + +``` +project-root/ +├── pkg/upload/upload.go # [MODIFY] 新增 FileDeleteCos 删除 COS 对象工具函数 +└── internal/service/clothes.go # [MODIFY] DeleteByUUID 删除后清理 COS;processImageAsync 增加删除竞态防护 +``` + +## 关键代码结构 + +``` +// FileDeleteCos 删除 COS 对象。key 为对象键(即 processed_image 存储值)。 +// client 未初始化返回 error;key 为空返回 nil。 +func FileDeleteCos(ctx context.Context, key string) error +``` \ No newline at end of file diff --git a/.codebuddy/plans/同步更新三个项目文档_5b365d72.md b/.codebuddy/plans/同步更新三个项目文档_5b365d72.md new file mode 100644 index 0000000..f7c2f70 --- /dev/null +++ b/.codebuddy/plans/同步更新三个项目文档_5b365d72.md @@ -0,0 +1,33 @@ +--- +name: 同步更新三个项目文档 +overview: 同步更新 README.md、接口文档.md、Stitch.md,反映本轮代码变更(广告激励配置开关+服务端定价、退款管理员操作、会员类型推导、iOS 退款问询细化等),并在 Stitch.md 额度商城页补充「看广告得额度」入口 UI。 +todos: + - id: update-readme + content: 更新 README.md 的 AdReward 配置段、错误码 80004、退款管理员操作与广告激励接口描述 + status: completed + - id: update-api-doc + content: 更新接口文档.md 的广告激励参数与错误码、订单状态机、会员类型推导说明 + status: completed + - id: update-stitch + content: 更新 Stitch.md 在额度商城页补充「看广告得额度」入口 UI 描述 + status: completed +--- + +## 用户需求 + +将 README.md、接口文档.md、Stitch.md 三个文档同步更新至最近一轮后端代码修复后的最新状态。 + +## 核心变更点(需映射到文档) + +1. 广告激励:新增 `AdReward` 配置段(`Enable` 开关 + `Cos`/`AI`/`OutfitShare`/`ClosetShare` 各类型固定金额);`/ad-reward/grant` 请求移除 `amount`,仅传 `reward_type` 与 `ref_id`;金额由服务端配置固定;新增错误码 `80004 广告激励未开启`。 +2. 退款管理员操作:订单「已取消但已付款」由「自动调微信退款」改为「生成待审核退款单,管理员后台手动退款」;退款成功回调兼容「已取消」订单流转且跳过权益回收。 +3. XPay 退款反查:退款推送回退权益前反查微信侧退款真实性。 +4. 会员类型推导:`vip_type` 按续费后最终到期时长推导(剩余不低于 365 天为年度,否则月度),体验会员保持不变。 +5. iOS 退款问询:按权益消耗情况细化拒绝。 +6. 上传接口:解析 multipart 前限制请求体大小。 + +## 更新范围 + +- README.md:配置说明表新增 `AdReward` 段;业务流程第 5 条退款描述;错误码段位表补 `80004`;API 概览广告激励行;额度体系措辞。 +- 接口文档.md:`/ad-reward/grant` 参数表删除 `amount` 并补业务逻辑与 `80004`;额度模块引言;微信回调竞态兜底描述;订单状态机 mermaid;会员类型推导规则;错误码全表补 `80004`;版本号与日期。 +- Stitch.md:在「额度与会员商城」页的「我的额度」卡片后补充「看广告得额度」入口 UI 描述(含开关控制、服务端定价、加载/失败态),严格沿用既有 iOS HIG 与 SF Symbols 风格。 \ No newline at end of file diff --git a/.codebuddy/plans/基于_golang-migrate_的数据表迁移_50fc3756.md b/.codebuddy/plans/基于_golang-migrate_的数据表迁移_50fc3756.md new file mode 100644 index 0000000..87a8f95 --- /dev/null +++ b/.codebuddy/plans/基于_golang-migrate_的数据表迁移_50fc3756.md @@ -0,0 +1,178 @@ +--- +name: 基于 golang-migrate 的数据表迁移 +overview: 使用 github.com/golang-migrate/migrate/v4 在 pkg/migration 下建立版本化迁移:22 张表的 up/down SQL(按业务域分组)+ embed 加载 + cmd/migrate 独立命令入口,并同步更新 go.mod 与 README。 +todos: + - id: setup-deps + content: 提升 go.mod 依赖:golang-migrate/v4 与 go-sql-driver/mysql 改为直接依赖并 tidy + status: completed + - id: write-migration-pkg + content: 实现 pkg/migration/migration.go:embed 源、DSN 拼装、Up/Down/Steps/Version/Force + status: completed + dependencies: + - setup-deps + - id: write-sql + content: 编写 22 张表的版本化 up/down 迁移 SQL 与全部索引 + status: completed + dependencies: + - setup-deps + - id: write-cmd + content: 实现 cmd/migrate 独立命令入口(up/down/steps/version/force) + status: completed + dependencies: + - write-migration-pkg + - id: verify-schema + content: 用 [subagent:code-explorer] 核对表字段与索引并修正差异 + status: completed + dependencies: + - write-sql + - id: update-readme + content: 更新 README 目录树与数据库准备章节的迁移说明 + status: completed + dependencies: + - write-cmd + - id: build-verify + content: 执行 go build ./... 与 go vet 验证编译通过 + status: completed + dependencies: + - verify-schema + - update-readme +--- + +## 产品概述 + +为 zt-closet-backend 引入基于 `github.com/golang-migrate/migrate/v4` 的版本化数据库迁移能力:把 `internal/mysqlmodel` 中 22 张表的结构固化为可执行、可回滚、可版本管理的 SQL 迁移脚本,并提供独立命令入口手动执行,服务启动流程保持不变。 + +## 核心功能 + +- **建表迁移脚本**:按业务域分组的版本化 SQL(up 建表 + 索引,down 为 DROP TABLE),覆盖全部 22 张表,字段/类型/注释与 GORM 模型严格一致 +- **索引完整性**:补齐 GORM tag 声明的全部唯一索引(幂等索引、联合唯一索引),并补充列表查询所需的普通索引 +- **迁移加载**:`go:embed` 将 SQL 打包进二进制,通过 `iofs` 作为 golang-migrate 数据源,部署无需额外携带 SQL 目录 +- **独立命令入口**:`cmd/migrate` 支持 `up` / `down` / `steps N` / `version` / `force `,仅读取 Mysql 配置段,服务启动时完全不触发迁移 +- **幂等与容错**:`migrate.ErrNoChange` 视为成功;提供 `force` 修复脏库(dirty)状态;失败以非 0 退出码返回 +- **文档同步**:改写 README 中「未内置建表 SQL,也不会自动建表」及占位目录说明,给出迁移命令用法 + +## 技术栈 + +- 语言/框架:Go 1.27,模块 `git.lzcwy.cn/zhengchiliu/zt-closet-backend` +- 迁移库:`github.com/golang-migrate/migrate/v4 v4.19.1`(当前为 indirect,需提升为直接依赖),配套 `database/mysql` 驱动、`iofs` 源 +- SQL 驱动:`github.com/go-sql-driver/mysql v1.10.0`(当前 indirect,需提升为直接依赖) +- 配置:`pkg/setting`(viper 只读 `configs/config.yaml`) +- 数据库:MySQL 5.7+/8.0,InnoDB + utf8mb4 + +## 实现方案 + +采用「嵌入 SQL + 独立命令执行」模式:迁移脚本作为版本化资产随代码评审与发布,运行时由 `cmd/migrate` 显式触发,服务主进程不接触 DDL,避免多实例并发迁移与启动期锁表风险。 + +关键决策与取舍: + +1. **embed 而非 file://**:迁移二进制可单独拷贝执行,避免工作目录/漏拷导致的版本漂移;切换 file:// 仅需替换源构造的 2 行代码,保留改造空间。 +2. **独立命令而非启动自动迁移**:主服务多实例部署时自动迁移会产生并发 DDL 与脏库风险;独立命令让 DDL 成为发布流程中的显式步骤。 +3. **自建 DSN 而非复用 gorm 连接**:gorm DSN 无 `multiStatements=true`,而单个迁移文件含多条 `CREATE TABLE`/`CREATE INDEX`,必须开启该参数;故在迁移包内按 `setting.MysqlSettingS` 拼装专用 DSN 并 `sql.Open("mysql", dsn)`,不改动既有 `NewMysqlEngine`。 +4. **按业务域分组版本(约 14 对文件)而非一表一版本**:22 张表一文件一对会导致文件过碎、回滚步骤过多;单文件全量则回滚粒度太粗。按域分组(用户/衣物/穿搭/衣柜分享/额度/商品/订单/退款/VIP/权益任务/AI 日志/审核日志/互动/通知/运势)在可读性与回滚粒度间平衡。 +5. **down 为 DROP TABLE**:本项目为初始建库,down 语义即整域回滚;按依赖倒序删除(明细表先于主表)。 + +性能与可靠性: + +- 迁移为一次性 DDL,非热路径;唯一风险点是 `multiStatements` 与脏库。通过 DSN 参数与 `force` 子命令覆盖。 +- 索引设计直接映射 GORM 唯一索引(保障 upsert 幂等:collects/likes/quota_flow_logs/vip_flow_logs/benefit_grant_tasks/horoscopes/user_quotas/user_daily_usages)与列表查询条件(user_id + 状态/分类),避免全表扫描。 +- `migrate.ErrNoChange` 与 `os.ErrNotExist`(无版本记录)均按成功处理,保证命令可重复执行。 + +## 架构设计 + +```mermaid +flowchart LR + A["cmd/migrate/main.go
解析子命令"] --> B["pkg/setting
读取 Mysql 段"] + B --> C["pkg/migration.Run
拼 DSN(multiStatements=true)
sql.Open"] + C --> D["iofs.New(embed FS, migrations)"] + C --> E["mysql.WithInstance(*sql.DB)"] + D --> F["migrate.NewWithSourceInstance"] + E --> F + F --> G["Up/Down/Steps/Version/Force"] + G --> H["schema_migrations 版本表"] +``` + +组件关系: + +- `cmd/migrate`:命令层,只做参数解析、配置读取与结果输出,不含业务逻辑 +- `pkg/migration`:能力层,封装 embed FS、DSN 构造、migrate 实例创建与 Up/Down/Steps/Version/Force +- `pkg/migration/migrations/*.sql`:数据资产层,版本化 DDL +- 依赖方向单向:`cmd/migrate → pkg/migration → pkg/setting`,主服务 `main.go` 不依赖迁移包 + +## 目录结构 + +``` +pkg/migration/ +├── migration.go # [NEW] 迁移能力封装://go:embed migrations/*.sql、iofs 源构造、DSN 拼装(含 multiStatements=true)、 +│ # Run/Up/Down/Steps/Version/Force 导出函数;ErrNoChange 视为成功 +└── migrations/ # [NEW] 版本化 SQL,命名 000001_xxx.up.sql / 000001_xxx.down.sql,约 14 对 + ├── 000001_create_users.{up,down}.sql + ├── 000002_create_clothes.{up,down}.sql + ├── 000003_create_outfits.{up,down}.sql # outfits + outfit_items + ├── 000004_create_closet_shares.{up,down}.sql # closet_shares + closet_share_items + ├── 000005_create_user_quotas.{up,down}.sql # user_quotas + user_daily_usages + ├── 000006_create_quota_flow_logs.{up,down}.sql + ├── 000007_create_goods.{up,down}.sql + ├── 000008_create_orders.{up,down}.sql + ├── 000009_create_refund_flow_logs.{up,down}.sql + ├── 000010_create_vip_flow_logs.{up,down}.sql + ├── 000011_create_benefit_grant_tasks.{up,down}.sql + ├── 000012_create_ai_flow_logs.{up,down}.sql # ai_recommend_flow_logs + ai_horoscope_flow_logs + ├── 000013_create_interactions.{up,down}.sql # collects + likes + ├── 000014_create_user_notifications.{up,down}.sql + ├── 000015_create_horoscopes.{up,down}.sql + └── 000016_create_audit_logs.{up,down}.sql # closet_audit_logs + outfit_audit_logs +cmd/ +└── migrate/ + └── main.go # [NEW] 独立迁移命令入口:子命令 up/down/steps N/version/force ; + # 仅 setting.NewSetting() + ReadSection("Mysql"),不调用 main.go 的 setupSetting() +go.mod # [MODIFY] golang-migrate/v4、go-sql-driver/mysql 由 indirect 提升为直接依赖 +go.sum # [MODIFY] 随 go mod tidy 更新(不手工编造哈希) +README.md # [MODIFY] 目录树新增 pkg/migration、cmd/migrate;改写 sql/、migrations/ 占位说明; + # 「准备数据库」章节改为 go run ./cmd/migrate up 等命令用法 +``` + +## 关键代码结构 + +``` +// pkg/migration/migration.go(仅签名,不含实现) +package migration + +//go:embed migrations/*.sql +var migrationFS embed.FS + +// Run 执行迁移动作;action 取值 up/down/steps/version/force。 +func Run(s *setting.MysqlSettingS, action string, arg int) error + +// 语义化封装,供命令层直接调用 +func Up(s *setting.MysqlSettingS) error // 执行到最新版本;ErrNoChange 视为成功 +func Down(s *setting.MysqlSettingS) error // 全量回滚(按依赖倒序 DROP) +func Steps(s *setting.MysqlSettingS, n int) error +func Version(s *setting.MysqlSettingS) (uint, bool, error) // 返回版本号与 dirty 状态 +func Force(s *setting.MysqlSettingS, version int) error // 修复脏库 +``` + +## 实现要点(防回归) + +- **DSN 必须带 `multiStatements=true`**,否则同文件多条语句会被 MySQL 拒绝;同时保留 `charset=utf8mb4&parseTime=True&loc=Local`。 +- **字段类型映射**:字符串按用途取 `VARCHAR(64)`(UUID/编码)、`VARCHAR(128/255)`(名称/短描述)、`TEXT`(description、content、result_content、callback_raw、daily_text、remark);状态/类型 `TINYINT`;时间统一 `BIGINT`(秒级时间戳);金额与计数 `BIGINT`(分);`deleted_at BIGINT NOT NULL DEFAULT 0`(soft_delete 插件以 0 表示未删除)。 +- **易错字段**:`clothes` 主色列名为 **`color`**(非 color_main),必须与模型一致,否则 GORM 查询报未知列。 +- **必须建立的唯一索引**(与 GORM tag 一一对应,缺失会破坏 upsert 幂等):`uk_user_wx_open_id`、`uk_user_quota(user_id,quota_type)`、`uk_daily_usage_user_date(user_id,stat_date)`、`uk_quota_flow_idem(order_no,quota_type,change_type)`、`uk_order_no`、`uk_refund_flow_no`、`uk_vip_flow_idem(order_no,change_type)`、`uk_benefit_task_idem(order_no,benefit_type)`、`uk_collect_user_target(collect_type,target_id,user_id)`、`uk_like_user_target(like_type,target_id,user_id)`、`uk_horoscope_date_zodiac(stat_date,zodiac)`。 +- **补充普通索引**(模型未声明但为查询条件):`clothes(user_id,category_main)`、`clothes(user_id,image_process_status)`、`outfits(user_id,status)`、`outfit_items(outfit_id)`、`closet_shares(user_id,status)`、`closet_share_items(share_uuid)`、各业务 UUID 列(clothes_uuid/outfit_uuid/share_uuid/notification_uuid/quota_flow_uuid)、`user_notifications(user_id,is_read)`、`orders(user_id,order_status)`。 +- **命令层不要复用 `main.go` 的 `setupSetting()`**(会强制校验 JWT 等无关配置,导致迁移命令在只配了数据库的环境失败);也不要 import `global` 之外触发 Redis/COS 初始化。 +- 日志/输出:命令层用 `fmt`/`slog` 输出版本号与执行结果,失败 `os.Exit(1)`;**不打印 DSN 明文密码**。 +- 本次**不连接真实数据库**(配置中的 MySQL 为内网地址),验证以 `go build`/`go vet` 为准。 + +## 验证清单 + +- `go build ./...` 通过,`go vet ./pkg/migration/... ./cmd/migrate/...` 无告警 +- `go run ./cmd/migrate version` 能正确加载配置并输出版本(连接失败时应给出可读错误而非 panic) +- 逐对检查 up/down 文件名配对、版本号连续无重复 +- 22 张表名、列名与 `internal/mysqlmodel` 模型逐一比对一致 + +## Agent Extensions + +### SubAgent + +- **code-explorer** +- Purpose:在迁移 SQL 编写完成后,交叉核对 `internal/mysqlmodel/` 下 18 个模型文件与 `pkg/migration/migrations/*.sql`,逐表比对表名、列名、字段类型与索引是否一致,重点核查 22 张表无遗漏、唯一索引与 GORM tag 完全对应 +- Expected outcome:输出差异清单(缺失表/错误列名/缺失或多余索引),并据此修正 SQL,确保迁移结果与 GORM 模型零偏差 \ No newline at end of file diff --git a/.codebuddy/plans/完善UserDefaultQuota配置化_ca78d85f.md b/.codebuddy/plans/完善UserDefaultQuota配置化_ca78d85f.md new file mode 100644 index 0000000..f7d457f --- /dev/null +++ b/.codebuddy/plans/完善UserDefaultQuota配置化_ca78d85f.md @@ -0,0 +1,89 @@ +--- +name: 完善UserDefaultQuota配置化 +overview: 补齐 UserDefaultQuota 配置链路:定义 setting.UserDefaultQuotaSettingS 结构体、main 读取配置、config 增加 UserDefaultQuota 段,并让 ensureDefaultQuota 改用配置值(配置缺省时回落现有常量兜底),修复当前因未定义结构体导致的编译错误。 +todos: + - id: define-setting + content: 在 pkg/setting/section.go 新增 UserDefaultQuotaSettingS 结构体(Cos/AI/OutfitShare/ClosetShare int64),修复 global/setting.go 的 undefined 编译错误 + status: completed + - id: wire-config + content: 在 main.go setupSetting 增加 ReadSection("UserDefaultQuota"),并在 config.yaml 与 config.example.yaml 新增 UserDefaultQuota 段(20/3/10/5) + status: completed + dependencies: + - define-setting + - id: service-use-config + content: 在 internal/service/quota.go 新增 defaultQuotaValue 辅助(判 nil、字段 + status: completed + dependencies: + - define-setting +--- + +## 需求概述 + +完善「新用户默认额度(UserDefaultQuota)」的配置化链路。当前 `global/setting.go` 已声明 `UserDefaultQuotaSetting *setting.UserDefaultQuotaSettingS`,但结构体未定义、main 未读取、config 无配置段、service 仍用硬编码常量,导致项目编译报错(`undefined: setting.UserDefaultQuotaSettingS`)。 + +## 核心功能 + +- 定义 `UserDefaultQuotaSettingS` 配置结构体,修复编译错误。 +- main 启动时读取 `UserDefaultQuota` 配置段。 +- 两个 config 文件新增 `UserDefaultQuota` 段(Cos/AI/OutfitShare/ClosetShare)。 +- `ensureDefaultQuota` 改为读取配置值,配置缺省(<=0 或 global 为 nil)时回落现有常量兜底。 +- 保持现有行为不变:未配置时默认仍为 20/3/10/5;初始化仍为 best-effort 且幂等。 + +## 技术方案 + +### 技术栈 + +沿用现有:Go + viper(`setting.ReadSection` → `UnmarshalKey`)配置读取,`global` 单例持有配置,`internal/service` 消费配置。 + +### 实现要点 + +1. **定义配置结构体** — `pkg/setting/section.go` 新增(放在 `WechatPaySettingS` 之后、`ReadSection` 方法之前,与现有结构体风格一致): + +``` +type UserDefaultQuotaSettingS struct { +Cos int64 // 免费上传额度 +AI int64 // 免费 AI 推荐额度 +OutfitShare int64 // 免费社区分享额度 +ClosetShare int64 // 免费衣柜分享额度 +} +``` + +修复 `global/setting.go:19` 的 `undefined` 编译错误。 + +2. **main 读取配置** — `main.go` `setupSetting()` 增 `setting.ReadSection("UserDefaultQuota", &global.UserDefaultQuotaSetting)`,与其他段处理方式一致(错误则 return err)。 + +3. **config 增加配置段** — `configs/config.yaml` 与 `configs/config.example.yaml` 均新增: + +``` +UserDefaultQuota: +Cos: 20 +AI: 3 +OutfitShare: 10 +ClosetShare: 5 +``` + +4. **service 使用配置 + 兜底** — `internal/service/quota.go`: + +- 新增取值辅助 `defaultQuotaValue(quotaType int8) int64`:判 `global.UserDefaultQuotaSetting` 是否为 nil;取对应字段;字段 `<= 0` 时回落 `constants.go` 的 `DefaultQuota*` 常量。 +- `ensureDefaultQuota` 的 defaults 列表 `total` 改为调用 `defaultQuotaValue(quotaType)`。 +- 语义约定:值 `<= 0` 视为「未设置」回落常量默认(本次不支持「显式 0 额度」,如需后续加开关,注释说明)。 +- `constants.go` 的四个 `DefaultQuota*` 常量**保留**作为兜底默认,补注释说明用途。 + +### 关键决策与约束 + +- **兜底优先,不破坏现有默认**:配置缺省或 global 为 nil(如未走 main 的单测场景)时结果仍为 20/3/10/5,避免空指针 panic。 +- **幂等/best-effort 不变**:`ensureDefaultQuota` 仍在 `WxLogin` 新用户分支中以 best-effort 调用,已存在则跳过,写流水(`QuotaChangeTypeActivity`)与同步 Redis 逻辑保持不变。 +- **安全**:纯数值配置,无秘钥、无外部输入风险。 + +### 涉及文件 + +- `pkg/setting/section.go`:[MODIFY] 新增 `UserDefaultQuotaSettingS`。 +- `main.go`:[MODIFY] `setupSetting` 增 `ReadSection("UserDefaultQuota", ...)`。 +- `configs/config.yaml`、`configs/config.example.yaml`:[MODIFY] 新增 `UserDefaultQuota` 段。 +- `internal/service/quota.go`:[MODIFY] 新增 `defaultQuotaValue` 辅助,`ensureDefaultQuota` 改用配置。 +- `internal/service/constants.go`:[MODIFY] 保留 `DefaultQuota*` 常量并补注释。 + +### 验证 + +- `go build ./...`、`go vet ./...` 通过(修复当前 undefined 错误)。 +- 逻辑核对:未配置该段时结果为 20/3/10/5;配置后按配置生效;`global.UserDefaultQuotaSetting` 为 nil 或字段 <=0 时不 panic 且回落常量。 \ No newline at end of file diff --git a/.codebuddy/plans/完善项目_README_文档_413d78c9.md b/.codebuddy/plans/完善项目_README_文档_413d78c9.md new file mode 100644 index 0000000..d0c1734 --- /dev/null +++ b/.codebuddy/plans/完善项目_README_文档_413d78c9.md @@ -0,0 +1,64 @@ +--- +name: 完善项目 README 文档 +overview: 为空白的 README.md 撰写一份完整的中文项目文档,涵盖项目简介、功能特性、技术栈、目录结构、快速开始(依赖/配置/数据库/启动)、API 概览、第三方 AI 服务说明、可观测性与部署等。 +todos: + - id: explore-details + content: 使用 [subagent:code-explorer] 补充确认 tokenhub/service/errcode/mysqlmodel 表结构/Redis 用途/AI 服务端点等 README 所需事实 + status: completed + - id: write-readme-core + content: 撰写 README 主体:项目简介、核心功能、技术栈、目录结构、快速开始(依赖/配置/基础设施/AI 服务/运行) + status: completed + dependencies: + - explore-details + - id: write-readme-api-ops + content: 撰写配置说明、API 概览表、可观测性与后台任务、部署提示与安全注意事项,并全文校对与代码事实一致性 + status: completed + dependencies: + - write-readme-core +--- + +## 用户需求 + +为 zt-closet-backend 项目(智能衣橱微信小程序后端)补全根目录下当前为空白的 README.md,产出一份与代码实际相符、可直接指导新开发者上手的完整中文项目文档。 + +## 文档内容要求 + +- **项目简介**:说明项目定位(智能衣橱/衣柜管理小程序后端服务),概括核心能力:衣物管理、AI 衣物分类与抠图、穿搭与 AI 推荐、运势生成、社区互动(分享/收藏/点赞/通知)、额度体系、商品与微信支付订单。 +- **技术栈**:如实列出 Go 1.27、Gin、GORM + MySQL、Redis、Viper、JWT、腾讯云 COS、微信小程序登录与 APIv3 支付、OpenTelemetry(Trace/Metric/Log)、slog + lumberjack。 +- **目录结构**:按已探明的真实结构逐层说明(configs/global/internal/pkg/thirdparty 等),空占位目录(docs、sql、migrations、scripts 子目录)不得虚构内容。 +- **快速开始**:环境依赖、配置准备(复制 config.example.yaml 并填写)、MySQL/Redis 准备、两个 Python AI 模型服务的安装与启动、后端编译运行、健康检查验证。 +- **配置说明**:逐一说明配置段落(Server/Log/Mysql/Redis/OTel/JWT/Cos/Upload/MattingModel/ClassificationModel/WechatMini/WechatPay/UserDefaultQuota/Tokenhub)与默认值。 +- **API 概览**:以表格列出 /ping 与 /api/v1 全部路由(公开路由与 JWT 鉴权路由分组),说明统一响应结构 {code, msg, data} 与 Bearer Token 鉴权方式。 +- **可观测性与后台任务**:OTel 上报、结构化日志(含 trace_id/span_id)、每日运势生成、权益补偿、订单超时过期等定时任务。 +- **安全注意事项**:JWT 密钥必填且须为强随机值、密钥仅存于配置文件不入库、微信回调验签等。 + +## 文档架构设计 + +README 采用面向开发者的标准结构,中文撰写,章节顺序遵循"是什么 → 能做什么 → 怎么跑起来 → 怎么配 → 有哪些接口 → 怎么运维"的认知路径: + +1. 项目简介 +2. 核心功能 +3. 技术栈 +4. 目录结构 +5. 快速开始(依赖 → 配置 → 基础设施 → AI 模型服务 → 运行) +6. 配置说明 +7. API 概览 +8. 可观测性与后台任务 +9. 部署提示 +10. 安全注意事项 + +## 写作规范(关键约束) + +- **事实准确性优先**:所有技术栈版本、路由、配置段、默认值必须与 go.mod、router.go、main.go、config.example.yaml 已探明内容一致;不得凭想象补充不存在的文件或机制。 +- **数据库初始化如实描述**:全仓未发现 AutoMigrate 调用与建表 SQL(sql/、migrations/ 目录为空),README 中须如实说明表结构需按 internal/mysqlmodel 的模型定义自行准备建表 SQL,不得虚构自动建表能力。 +- **配置机制如实描述**:当前 pkg/setting 仅从 configs/config.yaml 读取、不支持环境变量覆盖(最近刚完成"配置全面去环境变量化"改造);Tokenhub 段在 config.example.yaml 中缺失但 main.go 会读取(Model 默认 hy4、APIKey 为空时运势/AI 功能不可用),撰写时需核实 ReadSection 对缺失段的行为后如实说明。 +- **AI 服务说明**:如实描述 Fashion-CLIP 分类服务(15 维度、hf-mirror 镜像、端口 8000 约定)与 BiRefNet 抠图服务(BiRefNet_lite、本地缓存优先、BIREFNET_REPO_ID 可覆盖、端口 8001 约定)的依赖安装、GPU 建议与启动方式。 +- **安全提示**:JWT SecretKey 为空会导致启动 panic(代码强制校验)、微信支付 APIv3Key 必须 32 字节、密钥不入库、配置不入版本库(.gitignore 检查 config.yaml 是否被忽略)。 + +## 推荐使用的扩展 + +### SubAgent + +- **code-explorer** +- 用途:在撰写 README 前,系统性地补充确认剩余关键事实——pkg/tokenhub(fortune.go、outfit_recommend.go、scheduler.go 的职责与触发时机)、internal/service 各业务服务职责清单、pkg/errcode 错误码分组、internal/mysqlmodel 表模型清单、internal/redisdao 缓存用途、thirdparty 两个 Python 服务的路由端点、pkg/setting 的 ReadSection 对缺失配置段的行为。 +- 预期结果:形成一份可支撑 README 各章节的事实清单,确保文档内容与代码一一对应、无虚构。 \ No newline at end of file diff --git a/.codebuddy/plans/局部变量resp-req重命名为全称_419b3866.md b/.codebuddy/plans/局部变量resp-req重命名为全称_419b3866.md new file mode 100644 index 0000000..fba618f --- /dev/null +++ b/.codebuddy/plans/局部变量resp-req重命名为全称_419b3866.md @@ -0,0 +1,127 @@ +--- +name: 局部变量resp-req重命名为全称 +overview: 将项目中作为局部变量/函数参数的缩写 resp、req 统一重命名为 response、request,不改动 Dao 字段与 Go 惯用短名,不触碰 c.Request/http.Request 等标准库字段,保证编译与 vet 通过、对外行为不变。 +todos: + - id: scan-occurrences + content: 使用 [subagent:code-explorer] 全仓普查 \bresp\b、\breq\b 命中点,输出文件与行号清单,并标注需排除的 RequestID/c.Request/ResponseFormat 等完整标识符 + status: completed + - id: rename-handlers + content: 重命名 internal/routers/api/v1 全部 handler 中的局部 resp→response、req→request,借助 [skill:lsp-code-analysis] 做作用域内安全 rename 并同步引用 + status: completed + dependencies: + - scan-occurrences + - id: rename-service + content: 重命名 internal/service 各文件的参数名 req→request 与局部 resp→response,保持函数体引用一致,借助 [skill:lsp-code-analysis] 校验引用 + status: completed + dependencies: + - scan-occurrences + - id: rename-middleware-pkg + content: 重命名 internal/middleware/lzcwyjwt.go 及 pkg(wechat 等)中命中的局部 resp/req,排除类型名与导出符号 + status: completed + dependencies: + - scan-occurrences + - id: verify-build + content: 执行 gofmt、go build ./...、go vet ./... 并 grep 复核无残留独立 resp/req 变量、排除项未被改动 + status: completed + dependencies: + - rename-handlers + - rename-service + - rename-middleware-pkg +--- + +## 需求概述 + +将项目中作为**局部变量与函数参数**的缩写标识符 `resp`、`req` 统一重命名为全称 `response`、`request`,提升代码可读性与命名一致性。这是纯内部命名重构,不改变任何对外行为。 + +## 明确边界(已与用户确认) + +- **要改**:`resp → response`、`req → request`(仅限局部变量/函数参数标识符) +- **保留不改**: +- service 中的 `xxxDao` 字段(`Dao` 为领域专有缩写) +- Go 惯用短名:`ctx`、`err`、`ok`、`db`、`id`、`tx`、循环变量 `i` 等 +- 所有类型名/导出符号:`dto.XxxRequest`、`dto.XxxResponse`、`app.Response`、`app.NewResponse`、`ToResponse`、`ToErrorResponse`、`ResponseFormat`、`chatResponse`、`FortuneResponse` 等 +- 标准库/框架字段:`c.Request`、`http.Request`、`c.Request.Context()` +- 含 `req`/`resp` 的完整标识符:`RequestID`、`GetRequestID`、`requestIDKey`、`ShouldBindQuery`、`responseFormat` 等 + +## 验收标准 + +- 项目内不再有名为 `resp`/`req` 的独立局部变量/参数标识符 +- 上述排除项完全未被改动 +- `go build ./...`、`go vet ./...` 通过,对外 HTTP 行为、DTO JSON tag、路由完全不变 + +## 技术方案 + +### 重构策略 + +采用**逐文件、按 Go 标识符词边界精确替换**的方式(`\bresp\b → response`、`\breq\b → request`),仅作用于变量声明、赋值与引用位置。优先使用 LSP 作用域内重命名(rename symbol)保证不跨作用域误伤;对形态高度统一的 handler 文件可按文件精确替换后逐一校验。 + +### 命中范围(已通过 search_content 统计确认) + +**handler 层**(`internal/routers/api/v1/*.go`,形态统一): + +- `resp := app.NewResponse(c)` → `response := app.NewResponse(c)`,后续 `resp.ToResponse(...)` / `resp.ToErrorResponse(...)` 同步更新 +- `var req dto.XxxRequest`、`req.XxxUUID = c.Param(...)`、`&req` → 全部改为 `request` + +**service 层**(`internal/service/*.go`): + +- 方法参数名 `req *dto.XxxRequest` → `request *dto.XxxRequest`,函数体内引用同步更新 +- 局部 `resp`(如 `horoscope.go`、`user.go` 组装响应处)→ `response` + +**middleware / pkg**: + +- `internal/middleware/lzcwyjwt.go` 的 `resp := app.NewResponse(c)` +- `pkg/wechat/*`、`pkg/errcode`、`pkg/app` 等如有名为 `req`/`resp` 的局部变量/参数,一并处理(以实际命中为准) + +### 关键防回归点 + +- `\breq\b`(小写+词边界)天然不匹配 `c.Request`(大写 R)、`RequestID`、`requestIDKey`、`ShouldBindQuery`,但替换时须逐处确认无误伤 +- `\bresp\b` 不匹配 `ResponseFormat`、`responseFormat`、`chatResponse`、`FortuneResponse`、`RecommendLLMResponse`,保持不动 +- 类型名 `dto.*Request`/`dto.*Response`、`app.Response`/`app.NewResponse`/`ToResponse` 等导出符号绝不改动 +- 替换后须确认无与既有 `response`/`request` 标识符的命名冲突(当前代码未发现同名局部变量) + +### 涉及文件清单(预估,以实际命中为准) + +``` +internal/routers/api/v1/ +├── user.go # [MODIFY] resp/req → response/request +├── quota.go # [MODIFY] 同上 +├── horoscope.go # [MODIFY] 同上 +├── clothes.go # [MODIFY] 同上(含 multipart 分支) +├── outfit.go # [MODIFY] 同上(命中较多) +├── closet.go # [MODIFY] 同上 +├── collect.go # [MODIFY] 同上 +├── like.go # [MODIFY] 同上 +├── notification.go # [MODIFY] 同上 +├── goods.go # [MODIFY] 同上 +├── order.go # [MODIFY] 同上(含回调 handler) +└── airecommend.go # [MODIFY] 同上 +internal/service/ +├── user.go / quota.go / horoscope.go / clothes.go / outfit.go +├── closet.go / collect.go / like.go / notification.go +├── goods.go / order.go / airecommend.go # [MODIFY] 参数名 req→request、局部 resp→response +internal/middleware/ +└── lzcwyjwt.go # [MODIFY] resp → response +pkg/ +├── wechat/mini.go, wechat/pay.go # [MODIFY] 按实际命中的局部 req/resp +└── errcode/*, app/* # [CHECK] 按实际命中处理,类型/方法名不动 +``` + +### 验证方式 + +- `gofmt -l` 无格式问题 +- `go build ./...`、`go vet ./...` 通过 +- grep 复核:无残留作为独立标识符的 `\bresp\b`/`\breq\b` 局部变量;`c.Request`、`RequestID`、`dto.*Request`、`dto.*Response`、`ResponseFormat` 等未被改动 + +## Agent Extensions + +### SubAgent + +- **code-explorer** +- Purpose:在重构前对全仓库 `\bresp\b`、`\breq\b` 做精确命中普查,产出「文件 → 行号 → 上下文」清单,并甄别需排除的完整标识符(RequestID、c.Request、ResponseFormat 等),确保不漏改、不误伤。 +- Expected outcome:得到完整、去噪后的待改点清单,作为逐文件重命名的依据。 + +### Skill + +- **lsp-code-analysis** +- Purpose:对 `req`/`resp` 局部变量与函数参数执行 LSP 作用域内 rename(含引用点同步更新)与改前引用预览,避免跨作用域误改。 +- Expected outcome:每个 `resp`/`req` 标识符在其作用域内被安全重命名为 `response`/`request`,所有引用一致更新。 \ No newline at end of file diff --git a/.codebuddy/plans/微信支付切换到官方wechatpay-go_910cf320.md b/.codebuddy/plans/微信支付切换到官方wechatpay-go_910cf320.md new file mode 100644 index 0000000..ca63bca --- /dev/null +++ b/.codebuddy/plans/微信支付切换到官方wechatpay-go_910cf320.md @@ -0,0 +1,151 @@ +--- +name: 微信支付切换到官方wechatpay-go +overview: 将 pkg/wechat/pay.go 的自实现微信支付(签名/下单/退款/回调解密)替换为官方 SDK github.com/wechatpay-apiv3/wechatpay-go,获得平台证书自动下载轮换与应答/回调自动验签能力;同步调整订单回调链路以传入 *http.Request,code2session 登录保持标准库不变。 +todos: + - id: add-dep-init + content: 添加 wechatpay-go 依赖(go get + go mod tidy),基于 core.NewClient + WithWechatPayAutoAuthCipher 重写 InitPayClient,构建 notify.RSANotifyHandler,保留配置校验与单例 + status: completed + - id: rewrite-pay-refund + content: 用 SDK 重写 JsapiPrepay(PrepayWithRequestPayment 一步返回 *MiniPayParams,移除 BuildMiniPayParams)与 Refund(RefundsApiService.Create),并删除全部自实现签名/HTTP 辅助函数 + status: completed + dependencies: + - add-dep-init + - id: rewrite-notify + content: 用 handler.ParseNotifyRequest 重写两个回调函数,入参改为 *http.Request,分别用 payments.Transaction/refunddomestic.Refund 映射到现有 PayNotifyData/RefundNotifyData + status: completed + dependencies: + - add-dep-init + - id: adapt-callers + content: 适配 internal/service/order.go(Create 一步下单、PayNotify/RefundNotify 入参改 *http.Request、import net/http)与 internal/routers/api/v1/order.go(回调 handler 直接传 c.Request) + status: completed + dependencies: + - rewrite-pay-refund + - rewrite-notify + - id: verify + content: 执行 go build ./... 与 go vet ./...,使用 [subagent:code-explorer] 复核无残留 BuildMiniPayParams 及旧 []byte 回调调用,确认签名一致 + status: completed + dependencies: + - adapt-callers +--- + +## 产品概述 + +将微信支付能力从「Go 标准库自实现」切换为官方 SDK `github.com/wechatpay-apiv3/wechatpay-go`,在保持对外业务契约基本不变的前提下,获得官方 SDK 内置的**平台证书自动下载与轮换**、**请求签名与应答自动验签**、**回调自动验签 + 解密**能力,弥补自实现方案缺失的验签安全环节。 + +## 核心功能 + +- 商户支付客户端初始化:加载商户私钥、自动注册平台证书下载器(定期轮换)、自动应答验签。 +- 小程序 JSAPI 下单:一步返回小程序调起支付所需参数(TimeStamp/NonceStr/Package/SignType/PaySign)。 +- 申请退款:调用官方退款服务。 +- 支付/退款回调:官方处理器完成签名校验 + APIv3Key 解密 + 反序列化。 + +## 范围与约束 + +- 仅改造微信支付部分(`pkg/wechat/pay.go` 及其调用方)。 +- 微信小程序登录 `code2session`(`pkg/wechat/mini.go`)保持 Go 标准库实现,不改动。 +- 配置结构体 `setting.WechatPaySettingS` 字段不变;`main.go` 的初始化时机与「初始化失败仅告警、禁用支付、不 panic」降级逻辑保持不变。 +- 对外返回结构体(`MiniPayParams`/`PayNotifyData`/`RefundNotifyData`/`RefundResult`)字段保持不变,最小化上层改动。 + +## 技术栈 + +- 语言/框架:Go 1.27 + Gin(沿用现有项目)。 +- 新增依赖:`github.com/wechatpay-apiv3/wechatpay-go`(`go get` + `go mod tidy`,GOPROXY 已配置 goproxy.cn 可用)。 +- 复用 SDK 子包:`core`、`core/option`、`core/auth/verifiers`、`core/downloader`、`utils`、`services/payments/jsapi`、`services/payments`、`services/refunddomestic`、`core/notify`。 + +## 实现方案 + +### 1. 初始化 `InitPayClient(s *setting.WechatPaySettingS) error`(签名不变) + +- 保留原有配置完整性校验:字段非空、`APIv3Key` 必须 32 字节。 +- `utils.LoadPrivateKeyWithPath(s.PrivateKeyPath)` 加载商户私钥。 +- `core.NewClient(ctx, option.WithWechatPayAutoAuthCipher(mchID, serialNo, privateKey, apiV3Key))` 创建客户端:内部完成请求签名 + 平台证书自动下载/轮换 + 应答自动验签。 +- 用 `downloader.MgrInstance().GetCertificateVisitor(mchID)` + `verifiers.NewSHA256WithRSAVerifier(...)` 构造 `notify.NewRSANotifyHandler(apiV3Key, verifier)` 作为回调处理器。 +- 包级单例持有 `*core.Client`、`*notify.Handler`、`appID/mchID/notifyURL/refundNotifyURL`,沿用现有 `sync.RWMutex` + `getPayClient()` 模式。 +- 初始化会联网下载平台证书;失败返回 err(由 `main.go` 已有降级路径处理)。 + +### 2. 下单(合并两步为一步) + +- 用 `jsapi.JsapiApiService{Client}.PrepayWithRequestPayment(ctx, jsapi.PrepayRequest{Appid, Mchid, Description, OutTradeNo, NotifyUrl, Amount:{Total, Currency:"CNY"}, Payer:{Openid}})`,直接返回含 `TimeStamp/NonceStr/Package/SignType/PaySign` 的应答(SDK 内部已用商户私钥完成 paySign 签名)。 +- 接口调整:`JsapiPrepay(ctx, openid, orderNo, description string, amount int64) (*MiniPayParams, error)`(原返回 string prepayID)。 +- **移除 `BuildMiniPayParams`**(职责被 SDK 合并)。 +- 金额单位为分,用 `core.Int64(amount)` 等 SDK 指针辅助函数构造请求。 + +### 3. 退款 `Refund(...) (*RefundResult, error)`(签名不变) + +- 用 `refunddomestic.RefundsApiService{Client}.Create(ctx, refunddomestic.CreateRequest{OutTradeNo, OutRefundNo, NotifyUrl, Reason, Amount:{Refund, Total, Currency:"CNY"}})`。 +- 映射应答到 `RefundResult{RefundID, Status}`(注意 SDK 返回为指针,需判空取值)。 + +### 4. 回调验签 + 解密(关键变更:入参 `[]byte` → `*http.Request`) + +- `handler.ParseNotifyRequest(ctx, request, content)`:校验 `Wechatpay-Signature/Timestamp/Nonce/Serial` 签名头 → 平台证书验签 → APIv3Key 解密 → 反序列化。 +- `VerifyAndDecryptPayNotify(ctx context.Context, request *http.Request) (*PayNotifyData, error)`:content 用 `payments.Transaction`,映射到现有 `PayNotifyData`(含 `Amount.Total/PayerTotal/Currency`,指针判空)。 +- `VerifyAndDecryptRefundNotify(ctx context.Context, request *http.Request) (*RefundNotifyData, error)`:content 用 `refunddomestic.Refund`,`RefundStatus` 取 `refunddomestic.Status` 值映射。 + +### 5. 调用方适配 + +- `internal/service/order.go`: +- `Create`:改为一步 `payParams, err := wechat.JsapiPrepay(...)`,删除 `BuildMiniPayParams` 调用;`payParams → dto.WechatPayParams` 映射逻辑不变。 +- `PayNotify(ctx, request *http.Request) *errcode.Error` / `RefundNotify(ctx, request *http.Request) *errcode.Error`:入参由 `body []byte` 改为 `*http.Request`,转调新的 Verify 函数;幂等锁、状态条件流转、发放权益、通知等业务逻辑完全不变;新增 `net/http` import。 +- `internal/routers/api/v1/order.go`:回调 handler 不再 `c.GetRawData()`,直接 `svc.PayNotify(c.Request.Context(), c.Request)`;微信规范返回(`{"code":"SUCCESS"}`/`{"code":"FAIL"}`)不变。中间件链不消费 body,SDK 可正常读取。 +- `main.go`:`InitPayClient` 签名一致,无需改动。 + +### 6. 移除的自实现代码 + +删除 `pay.go` 中:`parsePrivateKey`、`doRequest`、`buildAuthorization`、`sign`、`randomHex`、`aesGCMDecrypt`、`decryptNotify`、`notifyEnvelope`、`payAPIBase`/path 常量、`PrepayResult` 等(被 SDK 取代)。`mini.go` 当前未使用 `randomHex`,无需保留。 + +## 架构与数据流 + +```mermaid +flowchart TD + A[order.go Create] --> B[wechat.JsapiPrepay 一步] + B --> C[jsapi.PrepayWithRequestPayment] + C --> D[core.Client 签名+应答验签] + E[api/v1 PayNotify/RefundNotify handler] --> F[svc PayNotify/RefundNotify ctx,*http.Request] + F --> G[wechat.VerifyAndDecrypt* ] + G --> H[notify.Handler.ParseNotifyRequest\n验签+解密+反序列化] + D --> I[(微信支付 API\napi.mch.weixin.qq.com)] + H --> J[downloader 平台证书自动轮换] +``` + +## 安全与健壮性 + +- 新增应答验签 + 回调验签 + 平台证书自动轮换,杜绝伪造/篡改回调,补齐自实现缺口。 +- 商户私钥/APIv3Key 仍仅来自配置文件路径,不硬编码、不写日志。 +- 回调验签失败返回微信 FAIL 触发重试;业务幂等(redis 锁 + 状态条件流转)保持不变,重复回调安全。 +- 初始化联网失败降级为「禁用支付」,不影响服务启动与其它业务。 + +## 目录结构 + +``` +zt-closet-backend/ +├── go.mod / go.sum # [MODIFY] 新增 wechatpay-go 依赖(go get + go mod tidy) +├── pkg/wechat/ +│ ├── pay.go # [REWRITE] 基于官方 SDK 重写:InitPayClient/Refund 签名不变; +│ │ # JsapiPrepay 返回 *MiniPayParams 并移除 BuildMiniPayParams; +│ │ # VerifyAndDecryptPayNotify/RefundNotify 入参改为 *http.Request; +│ │ # 删除全部手写签名/解密/HTTP 辅助函数 +│ └── mini.go # [KEEP] code2session 保持标准库实现,不改动 +├── internal/service/order.go # [MODIFY] Create 一步下单;PayNotify/RefundNotify 入参改 *http.Request;import net/http +├── internal/routers/api/v1/order.go # [MODIFY] 回调 handler 传 c.Request(不再 GetRawData) +└── main.go # [KEEP] InitPayClient 签名一致,初始化与降级逻辑不变 +``` + +## 关键代码结构(对外契约) + +``` +// pkg/wechat/pay.go 对外签名(结构体字段保持不变) +func InitPayClient(s *setting.WechatPaySettingS) error +func JsapiPrepay(ctx context.Context, openid, orderNo, description string, amount int64) (*MiniPayParams, error) // 返回类型调整 +func Refund(ctx context.Context, orderNo, refundNo string, total, refund int64, reason string) (*RefundResult, error) +func VerifyAndDecryptPayNotify(ctx context.Context, request *http.Request) (*PayNotifyData, error) // 入参改 *http.Request +func VerifyAndDecryptRefundNotify(ctx context.Context, request *http.Request) (*RefundNotifyData, error) // 入参改 *http.Request +// 移除:BuildMiniPayParams、PrepayResult 及所有自实现辅助函数 +``` + +## Agent Extensions + +### SubAgent + +- **code-explorer** +- Purpose:在改造完成后,全仓复核是否残留对已移除符号(`BuildMiniPayParams`、旧 `VerifyAndDecryptPayNotify([]byte)`/`VerifyAndDecryptRefundNotify([]byte)`)的调用,并确认 `order.go` 与 `api/v1/order.go` 与新签名一致。 +- Expected outcome:确认无遗漏引用,调用链签名对齐,避免编译期返工。 \ No newline at end of file diff --git a/.codebuddy/plans/扁平化迁移脚本目录结构_11e4276d.md b/.codebuddy/plans/扁平化迁移脚本目录结构_11e4276d.md new file mode 100644 index 0000000..5086306 --- /dev/null +++ b/.codebuddy/plans/扁平化迁移脚本目录结构_11e4276d.md @@ -0,0 +1,106 @@ +--- +name: 扁平化迁移脚本目录结构 +overview: 把 pkg/migration/migrations/*.sql 上移一层到 pkg/migration/*.sql,embed 改为 *.sql + iofs.New(fs, "."),并删除根目录多余的空占位目录 migrations/,同步更新 README 与验证。 +todos: + - id: flatten-sql + content: 将 32 个迁移 SQL 上移到 pkg/migration 并删除空的 migrations 子目录 + status: completed + - id: fix-embed + content: 修改 migration.go 的 embed 模式与 iofs 源路径为当前目录 + status: completed + dependencies: + - flatten-sql + - id: clean-root-dir + content: 删除仓库根空的 migrations 占位目录 + status: completed + - id: sync-readme + content: 同步 README 中所有 pkg/migration/migrations 路径引用 + status: completed + dependencies: + - fix-embed + - id: verify-build + content: 编译与 go vet 校验,并用临时测试确认 embed 源可解析(验证后删除) + status: completed + dependencies: + - fix-embed + - sync-readme + - id: review-paths + content: 用 [subagent:code-explorer] 复核目录结构与路径引用无残留 + status: completed + dependencies: + - verify-build +--- + +## 产品概述 + +调整已实现的数据库迁移目录结构:去掉 `pkg/migration/migrations/` 这一层多余嵌套,将版本化 SQL 直接放在 `pkg/migration/` 下,并清理仓库根已失效的 `migrations/` 占位目录;迁移入口 `cmd/migrate` 保留不动。 + +## 核心功能 + +- **SQL 目录扁平化**:32 个迁移脚本(`000001~000016` 的 up/down 配对)上移一层至 `pkg/migration/`,文件名与内容完全不变,删除空的 `migrations/` 子目录 +- **embed 路径适配**:改为 `//go:embed *.sql` + `iofs.New(migrationFS, ".")`,迁移脚本与 `migration.go` 同级 +- **清理根目录**:删除仓库根已无引用的空占位目录 `migrations/`(`sql/`、`scripts/`、`docs/` 保持不变) +- **文档同步**:README 目录树与「准备数据库」章节中的 `pkg/migration/migrations` 路径全部改为 `pkg/migration` +- **迁移能力不变**:`Up/Down/Steps/Version/Force` API、`cmd/migrate` 子命令、22 张表 SQL 内容均保持不变 + +## 技术栈 + +- Go 1.27,模块 `git.lzcwy.cn/zhengchiliu/zt-closet-backend` +- `github.com/golang-migrate/migrate/v4 v4.19.1`(`database/mysql` 驱动 + `source/iofs` 源) +- `github.com/go-sql-driver/mysql v1.10.0` + +## 实现方案 + +采用「脚本与代码同级」的扁平布局:`//go:embed *.sql` 把同目录 SQL 打进 embed FS,`iofs.New(migrationFS, ".")` 以当前目录作为 golang-migrate 源根目录,文件名 `00000N_xxx.up.sql` / `.down.sql` 保持 golang-migrate 规范不变。 + +关键决策: + +1. **去掉源子目录而非保留**:`iofs` 源只需一个目录根,SQL 与 `migration.go` 同级时路径最短、引用最少,且不损失任何能力(golang-migrate 按文件名而非目录识别版本)。 +2. **`//go:embed *.sql` 只嵌入 SQL**:embed FS 中不会出现 `.go` 文件,避免 `iofs` 源扫描到无关文件。 +3. **保留 `cmd/migrate`**:用户已确认接受 Go 标准布局;独立二进制使迁移不依赖主服务的 Redis/COS/支付等初始化。 +4. **不改文件名与内容**:避免已应用环境的版本号/校验和变化,保证迁移历史兼容。 + +## 实现要点(防回归) + +- `migration.go` 中 `migrationsDir` 常量改为 `"."` 并更新注释(迁移脚本与本文件同级);`iofs.New(migrationFS, migrationsDir)` 调用不变。 +- 文件名保持 `00000N_xxx.up.sql` / `00000N_xxx.down.sql`,**不要**重命名或改版本号。 +- 验证用临时测试文件放在 `pkg/migration` 下(仅 `iofs.New` + `src.First()`,不连数据库),**验证后必须删除**。 +- 不要改动 `cmd/migrate/main.go`、SQL 内容与迁移包对外 API。 +- 不连接真实数据库:`configs/config.yaml` 的 MySQL 为内网地址,且该文件第 2 行 `Domain: *` 为非法 YAML(用户本地配置,本次不处理)。 + +## 架构设计 + +```mermaid +flowchart LR + A["cmd/migrate/main.go
up/down/steps/version/force"] --> B["pkg/setting
读取 Mysql 段"] + B --> C["pkg/migration.Run
DSN multiStatements=true"] + C --> D["iofs.New(embed FS, .)"] + D --> E["pkg/migration/*.sql
000001~000016 up/down"] + C --> F["mysql.WithInstance(*sql.DB)"] + E --> G["migrate.NewWithInstance"] + F --> G + G --> H["schema_migrations"] +``` + +## 目录结构 + +``` +pkg/migration/ +├── migration.go # [MODIFY] //go:embed 由 migrations/*.sql 改为 *.sql;migrationsDir 常量由 "migrations" 改为 "."; +│ # Up/Down/Steps/Version/Force/Run 与 DSN 拼装逻辑不变 +├── 000001_create_users.up.sql # [MOVE] 由 migrations/ 上移,内容不变 +├── 000001_create_users.down.sql # [MOVE] +├── ...(000002~000016 的 up/down,共 32 个文件)[MOVE] +└── migrations/ # [DELETE] 空目录,扁平化后删除 +migrations/ # [DELETE] 仓库根空占位目录,已无引用 +README.md # [MODIFY] 目录树:pkg/migration 去掉 migrations 子层; + # 「准备数据库」章节与 sql/ 占位说明中的 pkg/migration/migrations 改为 pkg/migration +``` + +## Agent Extensions + +### SubAgent + +- **code-explorer** +- Purpose:扁平化完成后复核 `pkg/migration/` 目录结构与 README 中的路径引用,确认 `migrations/` 子目录与根占位目录已消失、无遗留 `pkg/migration/migrations` 文本引用、32 个 SQL 文件齐全且 up/down 配对完整 +- Expected outcome:输出路径引用与文件清单核查结果,确保无残留旧路径 \ No newline at end of file diff --git a/.codebuddy/plans/拆分_Stitch_提示词为分段可粘贴文档_fdfe8c49.md b/.codebuddy/plans/拆分_Stitch_提示词为分段可粘贴文档_fdfe8c49.md new file mode 100644 index 0000000..645fb95 --- /dev/null +++ b/.codebuddy/plans/拆分_Stitch_提示词为分段可粘贴文档_fdfe8c49.md @@ -0,0 +1,47 @@ +--- +name: 拆分 Stitch 提示词为分段可粘贴文档 +overview: 将现有 Stitch.md 的整段一次性提示词,改写为「第 1 段全局规范 + 第 2~14 段每页一段」共 14 段的分段结构,每段可独立复制粘贴到 Stitch。 +todos: + - id: rebuild-global-segment + content: 重写 Stitch.md 头部使用说明与第 1 段全局规范段 + status: completed + - id: verify-enums + content: 用 [subagent:code-explorer] 核对各屏字段与状态枚举真实取值 + status: completed + - id: migrate-core-pages + content: 迁移主流程 5 页为第 2~6 段(登录/衣橱/衣物详情/穿搭/AI 推荐) + status: completed + dependencies: + - rebuild-global-segment + - verify-enums + - id: migrate-minor-pages + content: 迁移次要流程 8 页为第 7~14 段(上传至我的) + status: completed + dependencies: + - migrate-core-pages + - id: proofread-segments + content: 全文校对:13 屏零丢失、段号连贯、锚点与负面约束一致 + status: completed + dependencies: + - migrate-minor-pages +--- + +## 产品概述 + +将现有给 Google Stitch 使用的 UI 生成提示词文档 `Stitch.md`,由「一次性整段粘贴」改写为「分段粘贴」结构:共 14 段,第 1 段为全局规范,第 2~14 段每页一段,每段可独立复制粘贴到 Stitch 逐次发送,解决整段过长导致发送失败/生成不稳定的问题。 + +## 核心功能 + +- **单文件分段结构**:仍在 `Stitch.md` 内,用「第 N 段|页面名」醒目标记分隔 14 段,每段标注「复制本段(不含标题行)粘贴到 Stitch」 +- **第 1 段 全局规范**:产品定位、微信小程序平台与画布约束、iOS 设计语言与色系字阶、导航结构与页面跳转关系、图标与组件规格、全局通用元素、负面约束、逐页生成约定 +- **第 2~14 段 逐页提示词**:按「先主流程、后次要流程」排序——登录、衣橱、衣物详情、穿搭、AI 穿搭推荐、衣物上传、穿搭详情、运势、衣柜分享、通知、额度与会员商城、订单、我的 +- **上下文锚点**:每段开头用 1~2 句压缩重申小程序 375×812、系统蓝 #007AFF、白卡 + #F2F2F7 底 + 发丝线等基准,保证分次发送时跨页风格一致 +- **内容零丢失**:现有 13 屏的全部示例文案、状态徽标、空态/加载态/异常态、负面约束完整迁移;衣柜分享一屏内含 ①②③ 三个子页面 + +## Agent Extensions + +### SubAgent + +- **code-explorer** +- Purpose:核对 `internal/dto/` 与 `internal/service/constants.go` 中衣物 15 维属性、四类额度、订单/分享/通知/商品状态枚举,确保分段后的示例文案与枚举取值与后端真实数据一致 +- Expected outcome:输出字段与枚举事实清单,作为分段迁移与最终校对的依据,杜绝虚构字段 \ No newline at end of file diff --git a/.codebuddy/plans/按层区分_DAO_span_命名_72d9748c.md b/.codebuddy/plans/按层区分_DAO_span_命名_72d9748c.md new file mode 100644 index 0000000..f3f4de6 --- /dev/null +++ b/.codebuddy/plans/按层区分_DAO_span_命名_72d9748c.md @@ -0,0 +1,124 @@ +--- +name: 按层区分 DAO span 命名 +overview: 将 mysqldao 的 span 名由 dao.X 改为 mysqldao.X、redisdao 的改为 redisdao.X,共 140 处,解决两层 span 名无法区分的问题。 +todos: + - id: rename-mysqldao + content: 批量替换 internal/mysqldao 下 109 处 span 前缀为 mysqldao. + status: completed + - id: rename-redisdao + content: 批量替换 internal/redisdao 下 31 处 span 前缀为 redisdao. + status: completed + - id: verify-rename + content: 用 [subagent:code-explorer] 复核前缀计数与无残留无错改 + status: completed + dependencies: + - rename-mysqldao + - rename-redisdao + - id: sync-readme + content: 在 README 目录说明中补充 span 命名约定 + status: completed + dependencies: + - verify-rename + - id: build-verify + content: 执行 go build ./... 与 go vet ./... 验证 + status: completed + dependencies: + - verify-rename + - sync-readme +--- + +## 产品概述 + +统一调整 OTel span 命名前缀:数据访问层的 span 名由通用的 `dao.XXX` 改为可区分来源的 `mysqldao.XXX` 与 `redisdao.XXX`,解决 MySQL 与 Redis 两层调用在链路追踪中无法区分的问题。 + +## 核心功能 + +- **MySQL 层改名**:`internal/mysqldao/` 下全部 109 处 span 名前缀 `dao.` 改为 `mysqldao.` +- **Redis 层改名**:`internal/redisdao/` 下全部 31 处 span 名前缀 `dao.` 改为 `redisdao.` +- **命名一致性保持**:仅改前缀,方法名(如 `UserInfoSet`)、业务逻辑、其他 span 属性均不变 +- **不影响其他层**:`internal/service`(`service.`)、`pkg/upload`(`upload.`)、`pkg/tokenhub`(`scheduler.`)保持原样 +- **可维护性**:README 目录说明补一句 span 命名约定,避免后续新增代码再用回 `dao.` + +## 技术栈 + +- Go 1.27,模块 `git.lzcwy.cn/zhengchiliu/zt-closet-backend` +- OpenTelemetry Trace(`global.Tracer`,OTLP gRPC 上报) +- span 创建统一写法:`ctx, span := global.Tracer.Start(ctx, "<前缀>.<方法名>")` + +## 实现方案 + +采用**目录限定的批量字符串替换**:把 `global.Tracer.Start(ctx, "dao.` 中的前缀分别替换为 `mysqldao.` / `redisdao.`,替换范围严格限定在 `internal/mysqldao/` 与 `internal/redisdao/` 两个目录。 + +关键决策与取舍: + +1. **前缀命名用 `mysqldao.` / `redisdao.` 而非 `mysql.` / `redis.`**:与包名(`package mysqldao` / `package redisdao`)完全一致,Go 项目中 span 名即包名+方法名,排查时可从 span 名直接定位到文件与包,无需映射记忆。 +2. **批量 sed 而非逐处手改**:140 处手工改必漏;用 `sed -i 's/Tracer\.Start(ctx, "dao\./Tracer.Start(ctx, "mysqldao./g'` 这类按目录执行的命令可保证一致性与完整性,且模式串锚定了 `Tracer.Start(ctx, "dao.` 全串,不会误伤字符串变量、注释或日志中的 `dao.`。 +3. **只改前缀,不改方法名**:方法名与所在函数名一一对应,改名会造成与函数名不一致的二次困惑;同时避免破坏任何按名称检索的既有习惯。 +4. **不引入常量**:项目其余前缀(`service.`、`upload.`、`scheduler.`)均为字面量,沿用字面量保持风格统一,不引入新的抽象层(YAGNI)。 +5. **不改动 service / upload / tokenhub**:这些层前缀本身已能区分,不属于本次「分不清」的范围,缩小改动面。 + +性能与可靠性: + +- span 名为启动期/调用期构造的字符串常量,改为更长前缀无运行时开销(无额外分配语义变化)。 +- 风险点仅为「替换不完整」或「误伤其他目录」,通过目录限定 + 替换后计数校验(mysqldao 109 / redisdao 31 / 残留 `dao.` 为 0)来兜底。 + +## 实现要点(防回归) + +- 替换模式必须锚定完整串 `global.Tracer.Start(ctx, "dao.`,避免误改 `mysqldao.`/`redisdao.` 之外的其它 `dao` 文本(如注释、错误信息)。 +- 严格限定目录:`internal/mysqldao/*.go` 与 `internal/redisdao/*.go`,**不要**对仓库根或 `internal/` 整体执行替换(会误伤 `internal/service` 中可能存在的同名串)。 +- 替换后必须核验:`internal/mysqldao` 与 `internal/redisdao` 中 `"dao.` 残留为 0;`"mysqldao.` 计数 109、`"redisdao.` 计数 31(与替换前一致)。 +- 未发现任何测试或断言依赖 span 名字符串,改名不会破坏断言;仍以 `go build ./...` + `go vet ./...` 作为回归基线。 +- 不改动 `configs/config.yaml`(第 2 行 `Domain: *` 为非法 YAML,属用户本地配置,本次不处理)。 + +## 架构设计 + +调用链与 span 命名分层(本次改动仅涉及最下两层的命名): + +```mermaid +flowchart TD + A["routers/api/v1 handler
Gin 中间件 server span"] --> B["internal/service
span: service.Xxx"] + B --> C["internal/mysqldao
span: mysqldao.Xxx(本次由 dao. 改名)"] + B --> D["internal/redisdao
span: redisdao.Xxx(本次由 dao. 改名)"] + C --> E[("MySQL")] + D --> F[("Redis")] +``` + +## 目录结构 + +``` +internal/mysqldao/ +├── quota.go # [MODIFY] 18 处 span 前缀 dao. → mysqldao. +├── outfit.go # [MODIFY] 13 处 +├── order.go # [MODIFY] 12 处 +├── clothes.go # [MODIFY] 9 处 +├── closet.go # [MODIFY] 8 处 +├── user.go # [MODIFY] 7 处 +├── horoscope.go # [MODIFY] 7 处 +├── benefit_grant_task.go # [MODIFY] 7 处 +├── collect.go # [MODIFY] 6 处 +├── like.go # [MODIFY] 6 处 +├── notification.go # [MODIFY] 6 处 +├── auditlog.go # [MODIFY] 4 处 +├── goods.go # [MODIFY] 3 处 +└── vipflowlog.go # [MODIFY] 3 处 +internal/redisdao/ +├── quota.go # [MODIFY] 6 处 span 前缀 dao. → redisdao. +├── collect.go # [MODIFY] 4 处 +├── like.go # [MODIFY] 4 处 +├── auth.go # [MODIFY] 4 处 +├── notification.go # [MODIFY] 4 处 +├── user.go # [MODIFY] 3 处 +├── horoscope.go # [MODIFY] 2 处 +├── lock.go # [MODIFY] 2 处 +├── outfit.go # [MODIFY] 1 处 +└── closet.go # [MODIFY] 1 处 +README.md # [MODIFY] 可选:目录树中 mysqldao/redisdao 说明补 span 命名约定(mysqldao.* / redisdao.*) +``` + +## Agent Extensions + +### SubAgent + +- **code-explorer** +- Purpose:替换完成后复核两个 DAO 目录的 span 命名:确认 `internal/mysqldao` 全部为 `mysqldao.` 前缀、`internal/redisdao` 全部为 `redisdao.` 前缀、无 `"dao.` 残留,且 `internal/service`、`pkg/upload`、`pkg/tokenhub` 的 span 名未被误改 +- Expected outcome:输出各目录前缀计数与残留清单,确保 140 处全部正确改名、零误伤 \ No newline at end of file diff --git a/.codebuddy/plans/接入微信虚拟支付_全终端__8cbaa9ab.md b/.codebuddy/plans/接入微信虚拟支付_全终端__8cbaa9ab.md new file mode 100644 index 0000000..7053d2b --- /dev/null +++ b/.codebuddy/plans/接入微信虚拟支付_全终端__8cbaa9ab.md @@ -0,0 +1,149 @@ +--- +name: 接入微信虚拟支付(全终端) +overview: 按微信官方政策把虚拟商品(会员/额度包)支付从普通 JSAPI 支付切换到小程序虚拟支付(wx.requestVirtualPayment,覆盖 Android/iOS 全终端):新增 pkg/wechat/virtualpay.go(xpay 服务器 API HMAC 签名客户端 + 事件推送验签/应答)、虚拟支付订单创建接口、道具服务器 API 同步上架、发货/退款/iOS 退款问询三类推送处理、goods/orders 扩展字段与新迁移、配置与文档同步;现有 JSAPI 支付代码保留但不再用于虚拟商品。 +todos: + - id: config-wiring + content: 新增 VirtualPaySettingS 配置结构与全局变量,接线 main.go 初始化与 config.example.yaml 样例段 + status: completed + - id: xpay-client + content: 实现 pkg/wechat/virtualpay.go 客户端(HMAC 签名、道具上传发布、查单退款、推送验签应答)并编写单元测试 + status: completed + dependencies: + - config-wiring + - id: data-layer + content: 扩展 goods/orders 模型与常量错误码,编写 000017 迁移 SQL,并用 [subagent:code-explorer] 校对一致性 + status: completed + - id: virtual-order + content: 实现道具同步 SyncVirtualGoods 与虚拟支付订单创建接口(service、dto、handler、路由) + status: completed + dependencies: + - xpay-client + - data-layer + - id: notify-callbacks + content: 实现发货/退款/iOS 退款问询推送回调处理并复用权益发放回退链路,调整退款申请分流 + status: completed + dependencies: + - xpay-client + - data-layer + - id: docs-verify + content: 同步 README 与接口文档,执行 go build 与 go vet 全量验证 + status: completed + dependencies: + - virtual-order + - notify-callbacks +--- + +## 产品概述 + +为微信小程序接入微信官方「虚拟支付(XPay)」能力:小程序内所有虚拟商品(会员、上传包、AI 推荐包、衣柜分享包、搭配分享包)的购买统一改走虚拟支付通道,覆盖 Android、iOS、鸿蒙、Windows 全终端(符合微信 2026-04-01 起虚拟商品全终端强制接入虚拟支付的政策要求)。现有普通微信支付(JSAPI)代码保留不删,供未来独立 App 或实物商品使用。 + +## 核心功能 + +- 配置与初始化:新增 VirtualPay 配置段(OfferID、现网/沙箱 AppKey、回调参数),服务启动时初始化虚拟支付客户端,未配置仅告警不阻断启动。 +- 道具同步:实现 xpay 服务器 API 客户端(HMAC 签名),将上架商品自动上传并发布为虚拟支付道具(一次一个、轮询任务状态),回填 product_id;失败告警不阻塞服务。 +- 虚拟支付下单:新增「创建虚拟支付订单」接口,本地落单(OutTradeNo 复用本地订单号、pay_channel=2、记录商品 product_id 与平台)后返回前端调起 wx.requestVirtualPayment 所需业务参数。 +- 发货推送回调:处理 xpay_goods_deliver_notify(验签、幂等、校验 ProductId 与实际支付金额后流转订单并幂等发放会员/额度权益),必须按微信规范应答 ErrCode=0。 +- 退款与 iOS 退款问询:处理 xpay_refund_notify 推送复用现有权益回退链路;处理 xpay_subscribe_ios_refund_query_notify(3 秒内应答建议退款/拒绝);iOS 订单退款申请接口提示走 App Store,Android 虚拟支付订单走 xpay 退款任务。 + +## 技术栈 + +- 语言与框架:Go 1.27 + Gin + GORM(复用现有项目栈) +- 新增模块:pkg/wechat/virtualpay.go(XPay 客户端,仅 Go 标准库 + 现有依赖,与现有 mini.go 风格一致) +- 数据迁移:沿用现有 golang-migrate 版本化迁移体系(pkg/migration/*.sql embed),新增 000017 版本 +- 可复用基建:redisdao.TryLock、redismodel.KeyLockPayCallback、benefit_grant_tasks 幂等发放、sendNotification、slog + +## 实现方案 + +微信虚拟支付是独立体系:无服务端下单 API(下单由前端 wx.requestVirtualPayment 内部完成),服务端职责为「道具上传发布 + 本地订单管理 + 推送回调发货/退款 + iOS 退款问询应答」。因此本次采用**双通道并存、虚拟支付旁路接入**的架构: + +```mermaid +flowchart LR + A[小程序前端] -->|全终端统一| B[POST /order/virtual 创建虚拟订单] + B --> C[service.VirtualCreate
本地落单 pay_channel=2] + C --> D[返回 product_id / order_no] + A -->|wx.requestVirtualPayment| E[微信虚拟支付
iOS走Apple 其余走微信] + E -->|xpay_goods_deliver_notify| F[POST /xpay/notify] + F --> G[验签 幂等锁 金额校验] + G --> H[复用 finalizePaidOrder
grantOrderBenefits 发放权益] + E -->|xpay_refund_notify| I[复用 settleRefundSuccess 回退权益] + E -->|xpay_subscribe_ios_refund_query_notify| J[3秒内应答 result_code] + K[道具同步 SyncVirtualGoods] -->|start_upload_goods 轮询 publish| E +``` + +关键决策: + +1. **订单与道具标识**:goods 表加 product_id(道具 ID,空表示未发布);orders 表加 pay_channel=2(虚拟支付)、xpay_product_id、xpay_platform(android/ios,下单时前端上报,用于退款分流)、xpay_env(0 现网 1 沙箱)。发货推送以 OutTradeNo 匹配本地 order_no。 +2. **金额校验**:发货推送 GoodsInfo.ActualPrice(分)必须等于订单 PayAmount,ProductId 必须等于订单 xpay_product_id,否则不流转不发货、记日志人工介入(沿用现有 PayNotify 的防篡改策略)。 +3. **权益发放零改造**:虚拟支付发货完全复用 finalizePaidOrder → grantOrderBenefits → benefit_grant_tasks 幂等凭证链路;退款推送复用 settleRefundSuccess → revokeOrderBenefits 回退链路。不新增第二套发放逻辑。 +4. **道具同步策略**:提供 SyncVirtualGoods(ctx) 服务方法(遍历上架且 product_id 为空或发布失败的 goods,逐个 upload → 轮询 → publish),启动时 goroutine 异步执行并告警,另暴露内部触发入口;单个商品失败不影响其他商品。 +5. **推送应答**:XPay 消息推送与普通 V3 回调不同(ErrCode 格式、最多重推 15 次),必须用独立应答函数,不能复用现有 wechatCallbackSuccess。 +6. **旧通道保留**:现有 JSAPI 下单/回调/退款代码不动;虚拟商品前端不再调用 POST /order 的 JSAPI 下单(保留给未来实物商品/独立 App)。 + +性能与可靠性: + +- 回调处理热路径与现有 PayNotify 一致:Redis 幂等锁(30 秒)+ 条件状态流转 + 并发竞态兜底,无新增瓶颈。 +- iOS 退款问询必须在 3 秒内应答:处理逻辑仅本地 DB 查询 + 构造应答,不做外部调用,保证时延。 +- 道具同步轮询使用短超时 HTTP 客户端(复用 10s 风格),启动异步执行不阻塞服务。 + +## 目录结构 + +``` +pkg/wechat/ +├── virtualpay.go # [NEW] XPay 客户端:HMAC 签名、道具上传/发布/查询、query_order/refund_order、推送验签解析与 ErrCode 应答 +└── virtualpay_test.go # [NEW] HMAC 签名样例、推送解析/应答单元测试(固定 appKey,不连真实微信) +pkg/setting/section.go # [MODIFY] 新增 VirtualPaySettingS 配置结构 +global/setting.go # [MODIFY] 新增 VirtualPaySetting 全局变量 +main.go # [MODIFY] ReadSection("VirtualPay") + setupVirtualPayClient() +configs/config.example.yaml # [MODIFY] 新增 VirtualPay 配置样例段(占位值 + 注释) +internal/service/ +├── constants.go # [MODIFY] PayChannelVirtual=2、GoodsProductStatus 常量、Platform 常量 +├── virtualpay.go # [NEW] SyncVirtualGoods 道具同步 + VirtualOrderCreate + 推送处理(发货/退款/iOS 退款问询) +internal/mysqlmodel/ +├── goods.go # [MODIFY] 加 ProductID、ProductStatus 字段 +└── order.go # [MODIFY] 加 XPayProductID、XPayPlatform、XPayEnv 字段 +internal/dto/order.go # [MODIFY] 新增 VirtualOrderCreateRequest/Response +internal/routers/api/v1/order.go # [MODIFY] 新增 VirtualCreate、XPayNotify handler + XPay 应答函数 +internal/routers/router.go # [MODIFY] 注册 POST /order/virtual(鉴权)与 POST /xpay/notify(公开) +pkg/errcode/order_code.go # [MODIFY] 新增虚拟支付相关错误码(道具未发布/虚拟支付未配置/iOS 退款走 App Store 等) +pkg/migration/ +├── 000017_add_virtual_pay_fields.up.sql # [NEW] goods/orders 加字段与索引 +└── 000017_add_virtual_pay_fields.down.sql # [NEW] 对应回滚 +README.md # [MODIFY] 配置表、支付架构说明、迁移版本数 +接口文档.md # [MODIFY] 虚拟支付下单接口、三类推送、前端 wx.requestVirtualPayment 调用契约 +``` + +## 关键代码结构 + +``` +// VirtualPaySettingS 微信虚拟支付(XPay)配置,与普通微信支付独立结算。 +type VirtualPaySettingS struct { + OfferID string // 虚拟支付 OfferID(AppID) + EnvAppKey string // 现网 AppKey + SandboxAppKey string // 沙箱 AppKey + Enable bool // 是否启用虚拟支付(未开通时服务降级为不可下单) +} + +// VirtualOrderCreateResponse 创建虚拟支付订单响应(前端据此调 wx.requestVirtualPayment)。 +type VirtualOrderCreateResponse struct { + OrderNo string `json:"order_no"` // 业务订单号(作为 OutTradeNo) + PayAmount int64 `json:"pay_amount"` // 实付金额(分) + ProductID string `json:"product_id"` // 虚拟支付道具 ID + Quantity int64 `json:"quantity"` // 购买数量(固定 1) + Attach string `json:"attach"` // 透传信息 +} +``` + +实现注意事项: + +- XPay 签名规则 `paySig = hex(hmac_sha256(appKey, uri + "&" + signData))`,服务器 API 的 uri 为 API 路径(不含 query),post_body 必须与真实请求体完全一致;推送验签以官方「虚拟支付」消息推送文档为准,实现时不得虚构字段。 +- xpay 请求域名与路径(/xpay/start_upload_goods 等)以官方文档为准;客户端初始化为全局单例 + RWMutex,风格对齐 pay.go。 +- 推送应答:成功返回 `{"ErrCode":0,"ErrMsg":"success"}`(JSON)或对应 XML;非 goods_deliver/refund/ios_refund_query 的推送(complaint/coin/wxpay_callback)仅结构化记日志并应答成功。 +- Env 字段解析保留(0 现网 / 1 沙箱),沙箱默认关闭(iOS 不支持沙箱)。 + +## Agent Extensions + +### SubAgent + +- **code-explorer** +- 用途:迁移 SQL 落地后,交叉核对 000017 迁移脚本与 internal/mysqlmodel 中 goods/orders 模型字段的一致性(列名、类型、默认值、索引),并复核全仓库无遗漏的 pay_channel=2 分支与错误码引用。 +- 预期结果:输出逐表差异清单,确认新增字段与索引与模型定义完全一致、无旧路径引用遗漏。 \ No newline at end of file diff --git a/.codebuddy/plans/收藏单条状态查询接口_352ab083.md b/.codebuddy/plans/收藏单条状态查询接口_352ab083.md new file mode 100644 index 0000000..adfb3d1 --- /dev/null +++ b/.codebuddy/plans/收藏单条状态查询接口_352ab083.md @@ -0,0 +1,73 @@ +--- +name: 收藏单条状态查询接口 +overview: 参照点赞的 GET /like/:targetUUID,为收藏新增 GET /collect/:targetUUID 单条收藏状态查询接口(返回 is_collected + collect_count),补齐前端进入穿搭详情时的收藏状态初始化能力。 +todos: + - id: add-collect-status-api + content: 新增收藏单条状态查询 DTO、Service、Handler 与路由注册 + status: completed + - id: sync-collect-docs + content: 同步 README 与接口文档,补充 GET /collect/:targetUUID 说明 + status: completed + dependencies: + - add-collect-status-api + - id: verify-collect-build + content: 执行 go build ./... 验证编译通过 + status: completed + dependencies: + - add-collect-status-api +--- + +## 用户需求 + +收藏状态无法正确初始化——后端缺少「单条收藏状态」查询接口。点赞已有 `GET /like/:targetUUID`(返回 `is_liked` + `like_count`),收藏仅有 `POST /collect`(收藏/取消)和 `GET /collect`(我的收藏列表),前端进入穿搭详情时无法获知当前用户是否已收藏该穿搭。 + +## 核心功能 + +参照点赞接口,为收藏补齐 `GET /collect/:targetUUID` 单条状态查询: + +- 返回当前用户是否已收藏(`is_collected`)与该穿搭收藏总数(`collect_count`) +- Redis 优先、异常回落 MySQL,与点赞查询策略严格对称 +- 校验穿搭存在性与可见性(非本人仅可查询公开可见穿搭) + +## 技术栈 + +- Go 1.27 + Gin + GORM + go-redis,沿用现有分层(handler → service → dao / redisdao) +- 复用现有收藏底层能力,不新增 DAO/Redis 方法 + +## 实现方式 + +严格对齐点赞 `GET /like/:targetUUID` 的实现模式,新增收藏单条状态查询链路。 + +### 关键决策 + +- **字段命名对称**:点赞 `is_liked`/`like_count`,收藏用 `is_collected`/`collect_count`。 +- **路径参数命名对齐**:`targetUUID`(驼峰,与点赞一致),避免前端两套传参方式不一致。 +- **回落策略一致**:是否已收藏与收藏总数均 Redis 优先;Redis 出错或计数为 0 时回落 MySQL 兜底,保证缓存冷启动/漂移时数据正确。 +- **仅新增不改旧**:收藏的收藏/取消与列表逻辑保持不动,降低回归风险。 + +### 数据流 + +`GET /collect/:targetUUID` → handler 取 `targetUUID` → service 校验穿搭(存在 + 可见性)→ Redis 查收藏状态/计数 → 异常回落 MySQL → 返回 `{is_collected, collect_count}` + +## 目录结构 + +``` +zt-closet-backend/ +├── internal/ +│ ├── dto/ +│ │ └── collect.go # [MODIFY] 新增 CollectGetByTargetRequest/Response +│ ├── service/ +│ │ └── collect.go # [MODIFY] 新增 GetByTarget 方法 +│ └── routers/ +│ ├── router.go # [MODIFY] 注册 GET /collect/:targetUUID +│ └── api/v1/ +│ └── collect.go # [MODIFY] 新增 GetByTarget handler +├── README.md # [MODIFY] API 概览收藏小节新增接口行 +└── 接口文档.md # [MODIFY] 接口总表与 5.8 节新增接口说明 +``` + +## 实现细节 + +- DTO 定义与点赞完全对应:`CollectGetByTargetRequest{ TargetUUID string json:"target_uuid" binding:"required" }`、`CollectGetByTargetResponse{ UserIsCollected bool json:"is_collected"; CollectCount int64 json:"collect_count" }`。 +- Service `GetByTarget` 内部逻辑:先 `OutfitGetByUUID`(不存在返回 `TargetNotFound`、非本人且未公开返回 `OutfitNoPermission`),再取 `targetID`,随后 `CollectIsMember`(Redis 优先,err 回落 `CollectGetByTargetAndUser` 判断 `Status == InteractStatusOn`),最后 `CollectCount`(Redis 优先,`err != nil || count == 0` 回落 `CollectCountByTarget`)。 +- Handler 与路由均对齐点赞:path 参数 `targetUUID`,空值返回 `InvalidParams`;路由注册在鉴权组内,`/collect`(精确路径)与 `/collect/:targetUUID`(参数路径)在 Gin 路由树中可共存。 \ No newline at end of file diff --git a/.codebuddy/plans/数据库唯一索引全面补齐_0a7c6d59.md b/.codebuddy/plans/数据库唯一索引全面补齐_0a7c6d59.md new file mode 100644 index 0000000..c42a402 --- /dev/null +++ b/.codebuddy/plans/数据库唯一索引全面补齐_0a7c6d59.md @@ -0,0 +1,82 @@ +--- +name: 数据库唯一索引全面补齐 +overview: 全面盘点代码中依赖数据库唯一索引做幂等/防重、但模型未声明 tag 的表,补全 GORM 模型 tag,并生成一份幂等的建索引 SQL 脚本供用户自行在开发库执行。 +todos: + - id: add-model-unique-index-tags + content: 为 vipflowlog、benefit_grant_task、refundflowlog、collect、like、order、quota(UserDailyUsage)、horoscope 八个模型补 uniqueIndex tag + status: completed + - id: create-sql-script + content: 新建 sql/unique_indexes.sql:9 条唯一索引 DDL、幂等检查语句、执行说明与重复数据清理提示 + status: completed + dependencies: + - add-model-unique-index-tags + - id: build-verify + content: 执行 go build、go vet 与 lint 检查,核对脚本索引与模型 tag 一一对应 + status: completed + dependencies: + - create-sql-script +--- + +## 产品概述 + +针对上一轮「配额流水事务一致性修复」遗留的数据库唯一索引落地问题,为开发环境提供一套完整的数据库唯一索引补齐方案。用户处于开发阶段、无历史业务数据,选择由本人后续自行在开发库执行 SQL,本次交付:补全所有依赖唯一索引做幂等/防重的模型声明,并生成可直接执行的幂等 SQL 脚本。 + +## 核心功能 + +- **补全模型唯一索引声明**:为 8 个依赖唯一索引但未声明 gorm tag 的模型补充 `uniqueIndex` 声明(自文档化,不影响当前运行)。 +- **生成唯一索引 SQL 脚本**:新建 `sql/unique_indexes.sql`,覆盖 9 个表的唯一索引 DDL,每条附用途说明与依赖代码位置。 +- **提供幂等执行与清理指引**:脚本头部提供索引存在性检查语句与重复执行说明,尾部提供开发环境重复测试数据的清理提示。 +- **构建验证**:确保 tag 改动无编译、静态检查错误。 + +## 索引清单 + +| 表 | 唯一索引(建议名) | 依赖场景 | +| --- | --- | --- | +| quota_flow_logs | uk_quota_flow_idem (order_no, quota_type, change_type) | 配额发放/回收幂等(tag 已补,仅需 SQL) | +| vip_flow_logs | uk_vip_flow_idem (order_no, change_type) | VIP 发放/回收幂等 | +| benefit_grant_tasks | uk_benefit_task_idem (order_no, benefit_type) | 权益发放补偿任务幂等凭证 | +| refund_flow_logs | uk_refund_flow_no (refund_no) | 退款申请防重 | +| collects | uk_collect_user_target (collect_type, target_id, user_id) | 收藏/取消收藏 upsert | +| likes | uk_like_user_target (like_type, target_id, user_id) | 点赞/取消点赞 upsert | +| orders | uk_order_no (order_no) | 商户订单号唯一 | +| user_daily_usages | uk_daily_usage_user_date (user_id, stat_date) | 每日计数行唯一,防并发重复行 | +| horoscopes | uk_horoscope_date_zodiac (stat_date, zodiac) | 每日星座运势防重复生成 | + + +## 技术栈 + +- 语言:Go +- ORM:GORM(`uniqueIndex` tag 声明,`priority` 控制联合索引列顺序) +- 数据库:MySQL 8.0(`ALTER TABLE ... ADD UNIQUE INDEX`) +- 脚本形式:纯 SQL 文件,人工执行 + +## 实现方案 + +### 总体策略 + +项目无迁移机制(无 AutoMigrate、无 .sql 文件、表结构外部管理),用户已明确选择「自己执行 SQL」,因此本次改动仅两处:模型 tag 补全 + 新增 SQL 脚本。不引入 AutoMigrate、不改服务启动行为。tag 声明仅作代码自文档化,为未来可能启用的自动迁移预留基础。 + +### 模型 tag 补全(8 个文件) + +1. `internal/mysqlmodel/vipflowlog.go`:`OrderNo` + `ChangeType` 联合索引 `uk_vip_flow_idem`(priority 1/2)。 +2. `internal/mysqlmodel/benefit_grant_task.go`:`OrderNo` + `BenefitType` 联合索引 `uk_benefit_task_idem`(priority 1/2)。 +3. `internal/mysqlmodel/refundflowlog.go`:`RefundNo` 单列唯一索引 `uk_refund_flow_no`。 +4. `internal/mysqlmodel/collect.go`:`CollectType` + `TargetID` + `UserID` 联合索引 `uk_collect_user_target`(priority 1/2/3)。 +5. `internal/mysqlmodel/like.go`:`LikeType` + `TargetID` + `UserID` 联合索引 `uk_like_user_target`(priority 1/2/3)。 +6. `internal/mysqlmodel/order.go`:`OrderNo` 单列唯一索引 `uk_order_no`。 +7. `internal/mysqlmodel/quota.go`(`UserDailyUsage`):`UserID` + `StatDate` 联合索引 `uk_daily_usage_user_date`(priority 1/2)。 +8. `internal/mysqlmodel/horoscope.go`:`StatDate` + `Zodiac` 联合索引 `uk_horoscope_date_zodiac`(priority 1/2)。 + +tag 格式遵循现有项目约定(如 `quotaflowlog.go`、`quota.go` 已声明的 `uniqueIndex:名称,priority:N` 风格),保持注释说明索引用途。 + +### SQL 脚本设计(新建 sql/unique_indexes.sql) + +- **头部**:执行说明(目标库、开发环境定位、执行方式 `mysql -u... -p... < sql/unique_indexes.sql`);MySQL 8.0 不支持 `CREATE INDEX IF NOT EXISTS`,提供基于 `information_schema.statistics` 的索引存在性检查模板查询。 +- **主体**:9 条 `ALTER TABLE xxx ADD UNIQUE INDEX xxx (...)`,按上表顺序排列,每条以注释块说明用途、依赖代码位置、重复执行时预期行为(已存在会报 Duplicate key name,可忽略或先删后建)。 +- **尾部**:开发阶段如有重复测试数据需先清理的提示,并给出关键表的查重 SQL 示例(如 `SELECT order_no, COUNT(*) FROM quota_flow_logs GROUP BY order_no, quota_type, change_type HAVING COUNT(*) > 1;` 模板)。 +- **不包含**:任何 `DROP TABLE`、`TRUNCATE`、删除数据的自动执行语句;清理数据仅以注释形式提示,由用户手动确认执行。 + +### 验证 + +- `go build ./...`、`go vet ./...`、`read_lints` 检查 tag 改动相关文件,确保无新增错误(tag 为纯字符串注释性元数据,预期零编译影响)。 +- 人工核对脚本中 9 个索引与模型中 tag 声明一一对应,列顺序与 `priority` 一致。 \ No newline at end of file diff --git a/.codebuddy/plans/新用户注册赠送体验会员_9ba5542c.md b/.codebuddy/plans/新用户注册赠送体验会员_9ba5542c.md new file mode 100644 index 0000000..0ab940f --- /dev/null +++ b/.codebuddy/plans/新用户注册赠送体验会员_9ba5542c.md @@ -0,0 +1,149 @@ +--- +name: 新用户注册赠送体验会员 +overview: 新增 vip_type=4 体验会员,并在微信登录首次注册时按可配置天数(默认 3 天,允许 0)幂等发放,同时下发系统通知;同步配置、常量、注释、迁移列注释与三份文档。 +todos: + - id: add-config + content: 新增 UserDefaultVipDays 配置段(section.go/global/main.go/config.example.yaml) + status: completed + - id: add-vip-type + content: 新增 VIPTypeTrial=4 与 DefaultVipDays 常量并同步模型与 DTO 注释 + status: completed + - id: impl-register-gift + content: 实现 ensureTrialVIP:幂等发放、回填缓存字段、系统通知 + status: completed + dependencies: + - add-config + - add-vip-type + - id: add-migration + content: 新增 000017 迁移更新三处 vip_type 列注释 + status: completed + dependencies: + - add-vip-type + - id: sync-docs + content: 同步接口文档、README 与 Stitch 的体验会员说明 + status: completed + dependencies: + - impl-register-gift + - id: verify + content: 编译校验并用 [subagent:code-explorer] 复核枚举同步与幂等缓存 + status: completed + dependencies: + - impl-register-gift + - add-migration + - sync-docs +--- + +## 产品概述 + +为「纸条AI衣橱」新增「新用户注册赠送体验会员」能力:微信首次登录自动注册后,同步赠送 3 天体验会员(新增 `vip_type=4` 体验会员类型),并给用户发一条系统通知;赠送天数可配置、可关闭,失败不影响登录。 + +## 核心功能 + +- **注册即送体验会员**:首次注册成功后自动开通 3 天体验会员(`vip_type=4`),到期时间按注册时间 + N×86400 秒累加 +- **新增会员类型**:`vip_type` 枚举新增 `4=体验会员`,与既有的非会员(1)/月度(2)/年度(3) 并列,模型、DTO、迁移列注释、接口文档、README、Stitch 设计稿同步 +- **天数可配置可关闭**:新增 `UserDefaultVipDays` 配置段,未配置回落内置默认 3 天;显式配 0 表示不赠送;负数视为非法回落默认(与 4 类免费额度的指针三态语义一致) +- **发放幂等**:复用 `vip_flow_logs` 的 `uk_vip_flow_idem(order_no, change_type)` 唯一索引,注册场景以 `register_gift_` 作为幂等键 +- **欢迎通知**:赠送成功后写入一条系统通知(「欢迎礼包已到账」),并递增未读徽标计数 +- **不影响登录**:注册赠送为 best-effort,任何失败仅告警;购买、退款回收、权益补偿等既有链路行为不变 + +## 技术栈 + +- 语言/框架:Go 1.27,模块 `git.lzcwy.cn/zhengchiliu/zt-closet-backend` +- 配置:`pkg/setting`(viper 只读 `configs/config.yaml`,`ReadSection` → `UnmarshalKey`/mapstructure) +- 存储:MySQL(GORM + 已落地的 golang-migrate 版本化迁移)、Redis(会话/用户信息缓存/未读计数) +- 可观测:OpenTelemetry span(命名已按层区分:`mysqldao.*` / `redisdao.*` / `svc.*`) + +## 实现方案 + +采用「复用既有会员发放链路 + 配置三态指针 + 幂等键带 userID」的方案:注册分支内调用与购买路径同一个 `VIPGrantIdempotent`(事务内原子累加 `vip_expire` 并写流水),只额外传入新的 `vipType=4` 与注册专用的幂等键,从而不新增表、不改发放语义、天然获得并发幂等能力。 + +关键决策与取舍: + +1. **复用 `VIPGrantIdempotent` 而非新写发放函数**:该函数第 51 行已把 `vipType` 作为入参,传 4 即可,SQL 无需改动;其原生 SQL `vip_expire = (CASE WHEN vip_expire > now THEN vip_expire ELSE now END) + days*86400` 保证并发安全与「已过期则重置」语义,重复实现反而易错。 +2. **幂等键用 `register_gift_`**:注册场景没有订单号,而唯一索引是 `(order_no, change_type)` 而非按 user 维度——order_no **必须带 userID**,若写死常量会导致全站只能发放一次(第二个用户注册即冲突回滚)。此做法与 `quota_flow_logs` 在非订单场景把流水 UUID 写入 `order_no` 的既有约定一致,`order_no VARCHAR(64)` 长度充足。 +3. **配置用对象段 + `*int64`**:`UserDefaultVipDays: { Days: 3 }`,与 `UserDefaultQuota` 同构、共用 `ReadSection`,零解码风险;指针用于区分「未配置(nil)→回落 3 天」与「显式 0→不赠送」,沿用刚落地的额度配置语义,避免二义。 +4. **不改已应用的迁移文件**:`000001`/`000010` 的列注释更新走新增的 `000017`(`ALTER TABLE ... MODIFY COLUMN`),保持迁移历史可重放、不漂移。 +5. **同步内存 user 对象再写缓存(易漏点)**:注册流程第 97 行 `redisdao.UserInfoSet(ctx, user, 0)` 会把内存对象写入缓存,若赠送后不更新 `user.VIPType/VIPExpire`,缓存会被写成「非会员」导致登录响应与缓存不一致。故赠送成功后必须回读用户并回填这两个字段。 +6. **响应层归一(可选,默认纳入)**:`buildUserInfoResponse` 在 `vip_expire <= now` 时把返回 `vip_type` 归一为 1,避免过期会员(尤其是 3 天体验会员)被前端误判为有效会员;只影响响应,不写库。 + +性能与可靠性:该路径仅在首次注册执行一次;复用既有事务与索引,无额外查询放大。风险点为「缓存被旧对象覆盖」与「幂等键冲突范围」,分别由第 5、2 点覆盖,并以 `go build`/`go vet` + 子代理复核兜底。 + +## 架构设计 + +```mermaid +flowchart TD + A["微信登录 /user/wxlogin
首次自动注册"] --> B["ensureDefaultQuota
4 类免费额度"] + B --> C["ensureTrialVIP
读 UserDefaultVipDays 配置"] + C -->|"days <= 0"| Z["不赠送,直接返回"] + C -->|"days > 0"| D["mysqldao.VIPGrantIdempotent
vipType=4, order_no=register_gift_uid"] + D --> E[("users.vip_type=4
vip_expire += days*86400")] + D --> F[("vip_flow_logs
uk_vip_flow_idem 幂等")] + D -->|成功| G["回读用户回填 VIPType/VIPExpire
→ UserInfoSet 缓存正确"] + G --> H["sendNotification
系统通知 + 未读 +1"] + D -->|唯一键冲突| I["视为已发放,跳过"] +``` + +组件关系:注册服务(`internal/service/user.go`)为唯一编排点,向下依赖 `mysqldao.VIPGrantIdempotent`、`redisdao.UserInfoSet/NotificationUnreadIncr`、`pkg/setting` 配置;与购买链路(`internal/service/order.go` 的 `grantVIP`)并行复用同一 DAO,互不侵入。 + +## 目录结构 + +``` +pkg/setting/section.go # [NEW 段] 新增 UserDefaultVipDaysSettingS{ Days *int64 },注释 nil/0/负数三态 +global/setting.go # [MODIFY] 新增 UserDefaultVipDaysSetting *setting.UserDefaultVipDaysSettingS +main.go # [MODIFY] 追加 ReadSection("UserDefaultVipDays", &global.UserDefaultVipDaysSetting) +configs/config.example.yaml # [MODIFY] 新增 UserDefaultVipDays 段(Days: 3 + 0/负数/删除语义注释) +internal/service/constants.go # [MODIFY] 新增 VIPTypeTrial int8 = 4;新增内置兜底常量 DefaultVipDays int64 = 3 +internal/service/user.go # [MODIFY] 新增 defaultVipDays() 与 ensureTrialVIP();注册分支调用并回读回填 user; + # buildUserInfoResponse 过期归一为 1(可选项,默认纳入) +internal/mysqlmodel/user.go # [MODIFY] vip_type 字段注释补 4=体验会员 +internal/mysqlmodel/vipflowlog.go # [MODIFY] old/new_vip_type 注释补 4=体验会员 +internal/dto/user.go # [MODIFY] vip_type 注释补 4=体验会员 +pkg/migration/000017_alter_vip_type_comment.up.sql # [NEW] ALTER 更新 users.vip_type、vip_flow_logs.old/new_vip_type 三处列注释 +pkg/migration/000017_alter_vip_type_comment.down.sql # [NEW] 恢复三处列注释为旧文案 +接口文档.md # [MODIFY] 360 行 vip_type 枚举补 4;488 行注册流程补「赠送体验会员」 +README.md # [MODIFY] 558 行字段枚举补 4;业务流程/配置表补 UserDefaultVipDays 说明 +Stitch.md # [MODIFY] 第 12 段会员卡、第 14 段会员标签补「体验会员」展示文案 +``` + +## 关键代码结构 + +``` +// internal/service/constants.go(新增) +const ( + VIPTypeNone int8 = 1 // 非会员 + VIPTypeMonthly int8 = 2 // 月度会员 + VIPTypeYearly int8 = 3 // 年度会员 + VIPTypeTrial int8 = 4 // 体验会员(注册赠送,天数由 UserDefaultVipDays 配置) +) + +// 内置兜底:仅在 UserDefaultVipDays 未配置或为负数时回落 +const DefaultVipDays int64 = 3 + +// internal/service/user.go(新增,仅签名) +// defaultVipDays 返回注册赠送的体验会员天数:配置未设置(nil)或负数回落 DefaultVipDays,显式 0 表示不赠送。 +func defaultVipDays() int64 + +// ensureTrialVIP 注册赠送体验会员(best-effort):赠送成功返回新的到期时间,未赠送或已发放返回 0。 +// 幂等键 register_gift_ 依赖 vip_flow_logs 的 uk_vip_flow_idem(order_no, change_type)。 +func ensureTrialVIP(ctx context.Context, userID int64) int64 +``` + +## 实现要点(防回归) + +- **幂等键必须含 userID**:`fmt.Sprintf("register_gift_%d", userID)`;写死常量会导致全站只能发放一次。 +- **缓存一致性**:赠送成功后必须回读用户并把 `VIPType/VIPExpire` 回填到内存 `user`,否则第 97 行 `redisdao.UserInfoSet(ctx, user, 0)` 会把「非会员」写进缓存。 +- **流水字段**:`ChangeType = VIPChangeTypeActivity(3) activity赠送`、`Source = VIPSourceSystem(3) 系统`、`Reason = "新用户注册赠送体验会员"`;`OldVIPType` 取回读前的值(注册场景为 1)。 +- **幂等判定**:`isDuplicateKey(err)` → 视为已发放直接返回,不视为失败;其它错误 `slog.WarnContext` 后继续,绝不阻断登录。 +- **通知文案**:`sendNotification(ctx, userID, 0, NotificationTypeSystem, "欢迎礼包已到账", "已为你开通 N 天体验会员,有效期至 YYYY-MM-DD")`,best-effort。 +- **不扩大范围**:`grantVIP`(购买)、`VIPRevokeIdempotent`(退款回收)、`benefit_grant_tasks` 补偿链路均不动;体验会员续费时 `user.VIPType != 1 && vip_expire > now` 会自然记为「续费」,符合预期。 +- **迁移 000017** 用 `ALTER TABLE ... MODIFY COLUMN` 时需完整重复列定义(`TINYINT NOT NULL DEFAULT 0 COMMENT '...'`),down 恢复旧注释;**不要**改动已应用的 000001/000010。 +- 不改动 `configs/config.yaml`(用户本地、已 gitignore;其第 2 行 `Domain: *` 为既有非法 YAML 问题,本次不处理)。 +- 验证:`go build ./...`、`go vet ./...` 通过;用 code-explorer 复核 vip_type 枚举全量同步、幂等键含 userID、注册后缓存不被写成非会员。 + +## Agent Extensions + +### SubAgent + +- **code-explorer** +- Purpose:实现完成后复核三件事——① 全仓库 vip_type 枚举说明(模型 2 处、DTO 1 处、迁移 3 处、接口文档 2 处、README 1 处、Stitch 2 段)是否全部补上 4=体验会员且无遗漏;② `ensureTrialVIP` 的幂等键是否带 userID、幂等分支是否正确、失败是否严格 best-effort 不阻断登录;③ 注册流程中内存 `user` 对象是否在赠送后被正确回填,避免缓存被写成非会员 +- Expected outcome:输出差异清单(遗漏位置 / 错误逻辑 / 缓存一致性风险),据其修正后保证注册赠送幂等、可配置、可关闭且与既有会员链路零冲突 \ No newline at end of file diff --git a/.codebuddy/plans/生成_Stitch_设计描述文档_fac81b47.md b/.codebuddy/plans/生成_Stitch_设计描述文档_fac81b47.md new file mode 100644 index 0000000..94e5649 --- /dev/null +++ b/.codebuddy/plans/生成_Stitch_设计描述文档_fac81b47.md @@ -0,0 +1,105 @@ +--- +name: 生成 Stitch 设计描述文档 +overview: 根据 zt-closet-backend(智能衣橱微信小程序后端)的真实能力,在已存在的空文件 /root/zt-closet-backend/Stitch.md 中撰写一份面向 Google Stitch 的完整描述:单一连续、可直接一次性粘贴的提示词,覆盖约 12 个微信小程序页面,视觉风格为现代简约。 +design: + architecture: + component: tdesign + styleKeywords: + - 现代简约 + - 浅色卡片 + - 充足留白 + - 细分割线 + - 单一强调色 + - 微信小程序规范 + fontSystem: + fontFamily: PingFang SC + heading: + size: 20px + weight: 600 + subheading: + size: 15px + weight: 500 + body: + size: 13px + weight: 400 + colorSystem: + primary: + - "#5B5BD6" + - "#8B87FF" + - "#F1F0FF" + background: + - "#F5F6F8" + - "#FFFFFF" + text: + - "#1A1A1C" + - "#6B7280" + - "#9CA3AF" + functional: + - "#16A34A" + - "#F59E0B" + - "#EF4444" + - "#3B82F6" +todos: + - id: extract-ui-fields + content: 用 [subagent:code-explorer] 核实 13 屏展示字段与枚举取值示例 + status: completed + - id: write-stitch-head + content: 撰写 Stitch.md 前半:产品定位、小程序平台约束、设计语言、导航结构 + status: completed + dependencies: + - extract-ui-fields + - id: write-stitch-screens + content: 续写 13 屏逐屏连续描述与空态、加载态、异常态 + status: completed + dependencies: + - write-stitch-head + - id: proofread-stitch + content: 校对 Stitch.md:枚举一致、小程序规范、无虚构功能、可整体粘贴 + status: completed + dependencies: + - write-stitch-screens +--- + +## 产品概述 + +为智能衣橱后端项目生成一份可直接粘贴给 Google Stitch 的 UI 设计提示词,写入项目根目录已存在的空文件 `Stitch.md`。描述面向**微信小程序**前端(非 Web/H5),覆盖全部业务页面,让 Stitch 一次性生成整套现代简约风格的小程序界面原型。 + +## 核心功能 + +- **单一完整描述**:整份文档即一段连续提示词,整体复制粘贴即可,不做分屏表格、不做多段 prompt +- **全量 13 屏**:微信授权登录、衣橱、衣物上传、衣物详情/编辑、穿搭列表、穿搭详情、AI 穿搭推荐、每日运势、衣柜分享、通知、额度与会员商城、订单、我的 +- **小程序平台约束**:顶部导航栏与右上胶囊按钮、底部 tabBar、375×812 逻辑分辨率、rpx 栅格、无浏览器地址栏 +- **现代简约设计语言**:浅灰底 + 白色卡片 + 细分割线 + 充足留白 + 克制中性色 + 单一强调色 + 线性图标 +- **每屏完整描述**:页面目标、顶部导航、内容区元素与文案、主要交互、空态/加载态/异常态 +- **真实业务状态呈现**:衣物处理状态(排队/抠图/分类/成功/失败)、订单状态(待支付/已支付/已取消/已过期/已退款)、分享状态(正常/已取消/已过期)、穿搭公开与审核、额度与会员、通知未读 +- **字段与枚举以代码为准**:衣物 15 维属性、运势四项评分与幸运色/数字、商品五类与价格为分、四类额度等,均取自项目真实数据结构,不虚构后端不存在的能力 + +## 设计方法 + +面向 Google Stitch 生成小程序原型:文档开头 1-3 行使用说明,其后为一段可整体复制的连续提示词正文。正文顺序为:产品一句话定位与目标用户 → 平台约束(微信小程序:状态栏、导航胶囊、tabBar、375×812 逻辑分辨率、rpx 栅格、无地址栏)→ 设计语言(浅灰页面底 + 白色卡片 + 细分割线 + 8pt 间距 + 统一圆角 + 单一强调色 + 线性图标 + 中文无衬线字阶)→ 导航结构 → 13 屏逐屏描述 → 全局通用元素(按钮、标签、弹窗、Toast、状态徽标、图片占位)。 + +页面导航结构: + +```mermaid +flowchart LR + S["微信授权登录"] --> A + A["tabBar 衣橱"] --> A1["衣物上传"] + A --> A2["衣物详情/编辑"] + B["tabBar 穿搭"] --> B1["穿搭详情"] + B --> B2["AI 穿搭推荐"] + C["tabBar 运势"] --> C1["每日运势详情"] + D["tabBar 通知"] --> D1["通知列表"] + E["tabBar 我的"] --> E1["额度与会员商城"] + E --> E2["订单列表"] + E --> E3["衣柜分享"] +``` + +每屏描述统一包含:页面目标、顶部导航标题与返回、内容区元素与示例文案、主要交互、空态/加载态/异常态。真实业务状态需内联到界面元素中(如衣物卡片角标"排队中/抠图中/分类中/失败"、订单卡片状态徽标、分享卡片"已过期"遮罩),使 Stitch 能生成对应状态样式。篇幅控制在 1200-2500 字,便于一次性粘贴。 + +## Agent Extensions + +### SubAgent + +- **code-explorer** +- Purpose:读取 internal/dto/ 下 13 个文件与 internal/service/constants.go,提取 13 屏所需的展示字段语义与取值示例(衣物 15 维属性字段名与示例值、穿搭/分享/订单/通知/商品/额度/运势列表项与详情字段),确保 Stitch 描述中的信息结构与真实数据结构一致 +- Expected outcome:输出按页面组织的字段事实清单(字段名 + 含义 + 示例取值 + 对应枚举),作为撰写 Stitch.md 的唯一数据依据,杜绝臆造字段与功能 \ No newline at end of file diff --git a/.codebuddy/plans/生成前端接口文档_05e564d5.md b/.codebuddy/plans/生成前端接口文档_05e564d5.md new file mode 100644 index 0000000..b9aabd9 --- /dev/null +++ b/.codebuddy/plans/生成前端接口文档_05e564d5.md @@ -0,0 +1,136 @@ +--- +name: 生成前端接口文档 +overview: 全量分析 zt-closet-backend 代码,在根目录 `接口文档.md`(当前为空文件)生成一份面向前端工程师的自包含接口文档:覆盖全部 42 个路由的字段级入参/出参、业务逻辑与状态机、调用时序图与 curl 示例、完整错误码表与前端处理约定。README.md 保持不变。 +todos: + - id: explore-facts + content: 用 [subagent:code-explorer] 全量抽取路由、DTO 字段与校验、路径参数名、业务规则与错误码事实清单 + status: completed + - id: doc-conventions + content: 撰写文档头、目录、通用调用约定、42 路由总表与公共数据结构 + status: completed + dependencies: + - explore-facts + - id: doc-user-module + content: 撰写用户、额度、运势模块的字段级接口详解 + status: completed + dependencies: + - doc-conventions + - id: doc-clothes-outfit-ai + content: 撰写衣物、穿搭、AI 推荐模块接口详解及异步处理/额度规则 + status: completed + dependencies: + - doc-conventions + - id: doc-social + content: 撰写衣柜分享、收藏、点赞、通知模块接口详解及交互规则 + status: completed + dependencies: + - doc-conventions + - id: doc-trade + content: 撰写商品、订单模块接口详解,标注支付回调为服务端专用 + status: completed + dependencies: + - doc-conventions + - id: doc-biz-errcode + content: 补写业务逻辑与状态机章节、mermaid 时序图、curl 示例、全量错误码表与前端 FAQ + status: completed + dependencies: + - doc-user-module + - doc-clothes-outfit-ai + - doc-social + - doc-trade + - id: doc-verify + content: 对照代码复核路径参数名、字段名差异、枚举取值与错误码,修正不一致 + status: completed + dependencies: + - doc-biz-errcode +--- + +## 用户原始需求 + +分析 `zt-closet-backend` 全量代码,生成一份**面向前端工程师**的接口文档,并且在文档中把**业务逻辑**也描述清楚。 + +## 澄清后的补充约定 + +1. **交付形式**:填充根目录已有的空文件 `接口文档.md`,产出一份自包含 Markdown 文档(含全部 42 个路由、字段级入参/出参、业务逻辑、时序图、错误码、curl 示例),可直接交付前端。不产出 OpenAPI/Swagger 文件。 +2. **内容范围(四项全选)**: + +- 字段级入参/出参表:必填性、类型、长度限制、枚举含义,必须以 `internal/dto` 的 `json`/`form`/`binding` tag 逐字段核对,不得臆造 +- 业务逻辑与状态机:额度扣减/回退、衣物 AI 处理状态机、订单状态机、穿搭公开/审核状态机、衣柜分享状态机、通知触发规则 +- 调用时序图 + 示例:mermaid 时序图(登录刷新、上传轮询、AI 推荐保存、下单支付、点赞收藏切换)+ curl/JSON 示例 +- 错误码全表与前端处理建议:按业务域列出全部错误码,说明 401 刷新 token、以 `code` 为准、金额单位为分等前端约定 + +3. **README.md 保持不动**:允许与接口文档有少量重复;发现 README 与代码不一致时,只以代码为准写入接口文档,不修改 README。 + +## 核心产出内容 + +- 文档头与服务信息:BasePath `/api/v1`、环境说明、鉴权方式 +- 通用调用约定:Bearer Token、统一响应结构、HTTP 状态码与业务码映射、分页约定、金额单位、时间戳单位、幂等约定、轮询策略 +- 42 个路由总表(公开 6 + 鉴权 35 + 健康检查 1) +- 11 个业务模块的接口详解(用户、额度、运势、衣物、穿搭、衣柜分享、收藏、点赞、通知、商品、订单、AI 推荐) +- 公共数据结构定义(user_info / ClothesItem / OutfitItem / GoodsItem / OrderItem / NotificationItem / QuotaFlowItem) +- 业务逻辑章节:7 条业务主线 + 状态机图 + 额度规则 + 通知触发规则 +- 5 张 mermaid 时序图 + curl/JSON 示例 +- 全量错误码表(按业务域分段)+ 前端处理建议 +- 前端联调 FAQ / 检查清单 + +## 约束 + +- 唯一写入文件:`/root/zt-closet-backend/接口文档.md` +- 所有路径、字段名、枚举值、错误码必须与代码一致;微信支付/退款回调需标注"服务端专用,前端禁止调用" + +## 技术栈 + +- 项目:Go 1.27 + Gin + GORM/MySQL + go-redis + JWT + 腾讯云 COS + 微信支付 V3 + OpenTelemetry +- 文档:Markdown(GFM 表格)+ Mermaid(`sequenceDiagram` / `stateDiagram-v2` / `flowchart`),零新增依赖、零代码改动 + +## 实施策略 + +"事实抽取 → 分模块撰写 → 交叉复核"三段式: + +1. **事实抽取**:以代码为唯一事实源,按顺序读取 + +- `internal/routers/router.go`:42 个路由的方法、路径、是否鉴权、路径参数名 +- `internal/dto/*.go`(13 个):请求/响应字段、`binding` 校验规则(必填/最大长度/枚举 `oneof`/数组 `min,max`)、`json` 与 `form` 差异 +- `internal/routers/api/v1/*.go`(12 个):参数来源(json/query/path/form)、路径参数真实名称(如 `:targetUUID`、`:orderNo`) +- `internal/service/*.go`(16 个):业务规则、状态机、额度扣减/回退、幂等、异步流水线、通知触发 +- `internal/mysqlmodel/*.go`:枚举值语义与字段含义 +- `pkg/errcode/*.go`(10 个):全量错误码与文案 +- `pkg/app/`:统一响应结构 `{code, msg, data}` 与 `details` 行为 +- `internal/middleware/`:JWT 鉴权、401/403 行为 + +2. **分模块撰写**:按业务域顺序写入同一文件,先写通用约定与公共数据结构,再写各模块接口,最后写业务规则、时序图、错误码与 FAQ,保证前端从上到下即可读懂。 +3. **交叉复核**:逐项比对路由总数、路径参数名、请求/响应字段名差异、枚举取值,确保无遗漏无臆造。 + +## 关键设计决策 + +- **单一文件自包含**:前端只需一个文件,不拆分为多文档;用锚点目录做导航。 +- **公共数据结构集中定义**:`user_info`、ClothesItem、OutfitItem、GoodsItem、OrderItem、NotificationItem、QuotaFlowItem 在"公共数据结构"章节统一定义,接口章节引用,避免重复与不一致(DRY)。 +- **坑位显式高亮**:对已确认的不一致点加粗标注——更新衣物的主颜色请求字段为 `color`(响应为 `color_main`);`GET /like/:targetUUID`、`POST /order/:orderNo/cancel` 的路径参数名非 `uuid`;通知 `is_read` 为 `1 未读 / 2 已读` 而非布尔;支付/退款回调不走统一响应且前端禁止调用。 +- **业务与接口分离但互链**:业务逻辑章节独立成章(状态机 + 规则),接口章节每个接口挂"业务逻辑"小节与相关错误码,双向锚点引用。 + +## 实施注意事项 + +- **准确性优先**:每个字段表必须来自 dto tag 而非 README;README 仅作素材参考,遇冲突以代码为准且**不改动 README**。 +- **性能/规模**:文档较长(预计 1500+ 行),分批追加写入,避免单次超长写入出错;表格列保持统一(字段 / 类型 / 必填 / 说明)。 +- **兼容性**:不改动任何 Go 代码、配置与路由,零运行时影响。 + +## 目录结构 + +``` +/root/zt-closet-backend/ +└── 接口文档.md # [MODIFY] 当前为空文件。写入完整前端接口文档:服务信息与 BasePath、通用调用约定 + # (鉴权/统一响应/HTTP 与业务码映射/分页/金额与时间单位/幂等/轮询)、42 个路由总表、 + # 公共数据结构、11 个业务模块字段级接口详解(含业务逻辑说明)、7 条业务主线与状态机 + # (额度/衣物 AI/穿搭/衣柜分享/订单/AI 推荐/通知)、5 张 mermaid 时序图 + curl/JSON + # 示例、全量错误码表、前端联调 FAQ 与检查清单。 +``` + +只读参考(不修改):`internal/routers/router.go`、`internal/dto/*.go`、`internal/routers/api/v1/*.go`、`internal/service/*.go`、`internal/mysqlmodel/*.go`、`pkg/errcode/*.go`、`pkg/app/`、`internal/middleware/`、`README.md` + +## Agent Extensions + +### SubAgent + +- **code-explorer** +- 用途:全量扫描 `internal/routers`、`internal/dto`、`internal/routers/api/v1`、`internal/service`、`internal/mysqlmodel`、`pkg/errcode`、`pkg/app`、`internal/middleware`,抽取路由清单、字段级 DTO 与校验规则、路径参数名、业务状态机与规则、全量错误码 +- 预期产出:一份可直接用于撰写的事实清单(路由 × 参数 × 响应 × 枚举 × 错误码 × 业务规则),保证文档零臆造 \ No newline at end of file diff --git a/.codebuddy/plans/生成数据库_ER_图_drawio_d0c6a093.md b/.codebuddy/plans/生成数据库_ER_图_drawio_d0c6a093.md new file mode 100644 index 0000000..1d59ea2 --- /dev/null +++ b/.codebuddy/plans/生成数据库_ER_图_drawio_d0c6a093.md @@ -0,0 +1,69 @@ +--- +name: 生成数据库 ER 图 drawio +overview: 基于 internal/mysqlmodel 下 22 张表模型,生成可直接用 draw.io 打开编辑的整体数据库 ER 图文件(docs/zt-closet-er.drawio),按业务域分组布局、全字段展示、标注主键/唯一索引/外键关系。 +todos: + - id: create-drawio-skeleton + content: 创建 docs/zt-closet-er.drawio 骨架:四个业务域分区容器(背景色、标题、图例)并规划网格坐标 + status: completed + - id: draw-user-domain-entities + content: 绘制用户域 5 张表实体(users、user_quotas、quota_flow_logs、user_daily_usages、user_notifications),含全字段、PK/UK 标注 + status: completed + dependencies: + - create-drawio-skeleton + - id: draw-wardrobe-domain-entities + content: 绘制衣橱域 9 张表实体(clothes、outfits、outfit_items、closet_shares、closet_share_items、collects、likes、两个 audit_logs) + status: completed + dependencies: + - create-drawio-skeleton + - id: draw-trade-ai-entities + content: 绘制交易域 5 张表(goods、orders、refund_flow_logs、vip_flow_logs、benefit_grant_tasks)与 AI/运营域 3 张表(horoscopes、ai_recommend_flow_logs、ai_horoscope_flow_logs) + status: completed + dependencies: + - create-drawio-skeleton + - id: draw-relationships + content: 按 20 条关系清单绘制 1:N 连线(乌鸦脚样式、1/N 标签、就近路由),重点优化 users 13 条关联线 + status: completed + dependencies: + - draw-user-domain-entities + - draw-wardrobe-domain-entities + - draw-trade-ai-entities + - id: validate-xml-layout + content: 用 Python xml.etree 或 xmllint 校验 XML 合法性,抽查坐标无重叠、连线端点 id 引用完整,修正布局问题 + status: completed + dependencies: + - draw-relationships +--- + +## 产品概述 + +基于项目现有 22 张数据库表(internal/mysqlmodel 全部模型),生成一份完整的数据库 ER 图,输出为 draw.io 可打开、可编辑的 .drawio 文件,纳入项目 git 管理。 + +## 核心功能 + +- **22 张表实体完整呈现**:每张表以「表头 + 字段行」表格样式展示全部字段(含类型、注释),主键加粗标注,唯一索引字段标注 UK,外键字段标注 →目标表。 +- **四个业务域分区布局**:用户域、衣橱域、交易域、AI/运营域,每域使用不同底色容器与标题区分,域内表就近排布。 +- **关系连线**:按盘点出的 20 条 1:N 关系绘制连线(users 关联 13 张子表、outfits/clothes/goods/orders/closet_shares/horoscopes 各自关联),采用「就近直连 + 简洁路由」保证可读性,连线端使用乌鸦脚样式并标注 1:N。 +- **可编辑可扩展**:文件为标准 mxGraph XML 格式,可用 draw.io 桌面版/网页版直接打开编辑,后续模型变更可手工维护。 + +## 技术方案 + +- 输出格式:draw.io 原生 mxGraph XML(手写生成,无需第三方库),文件名 `docs/zt-closet-er.drawio`(新建 docs 目录)。 +- 布局方案:纵向四层分区(用户域 → 衣橱域 → 交易域 → AI/运营域),每域一个带背景色的父容器(mxCell vertex container),表实体以固定尺寸(约 320x220 起,随字段数增高)网格排布,坐标手工规划避免重叠;users 表置于左上角作为中心实体,减少其 13 条关联线的交叉。 +- 实体结构:表头行显示表名(含色带与白字),字段行格式为「字段名: 类型」+ 注释,PK 加粗、UK 标注唯一索引名、FK 后缀 →目标表;Model 公共字段(created_at/updated_at/deleted_at/created_by/updated_by/deleted_by)在大表以「+ 公共字段」折叠行表达,避免图面过长。 +- 连线:使用 mxCell edge,source/target 指向实体 id,末端样式(endArrow/endSize/startFill)表达 1:N,edgeLabel 标注「1」与「N」;users 的关联线按目标域就近出口,避免长线穿越。 +- 校验:生成后用 Python `xml.etree.ElementTree` 或 `xmllint` 解析验证 XML 合法性,并抽查坐标无重叠、连线端点引用的 id 均存在。 + +## 实现细节 + +- 实体清单与字段来源:internal/mysqlmodel 下 22 个模型(users、user_quotas、quota_flow_logs、user_daily_usages、user_notifications、clothes、outfits、outfit_items、closet_shares、closet_share_items、collects、likes、outfit_audit_logs、closet_audit_logs、goods、orders、refund_flow_logs、vip_flow_logs、benefit_grant_tasks、horoscopes、ai_recommend_flow_logs、ai_horoscope_flow_logs)。 +- 唯一索引标注:uk_user_wx_open_id、uk_user_quota、uk_quota_flow_idem、uk_daily_usage_user_date、uk_collect_user_target、uk_like_user_target、uk_order_no、uk_refund_flow_no、uk_vip_flow_idem、uk_benefit_task_idem、uk_horoscope_date_zodiac。 +- 关系清单(20 条 1:N):users→clothes/outfits/closet_shares/user_quotas/user_daily_usages/user_notifications/orders/refund_flow_logs/vip_flow_logs/benefit_grant_tasks/collects/likes/ai_recommend_flow_logs;outfits→outfit_items/outfit_audit_logs/collects/likes;clothes→outfit_items/closet_share_items;closet_shares→closet_share_items/closet_audit_logs;goods→orders/benefit_grant_tasks;orders→refund_flow_logs/benefit_grant_tasks;horoscopes→ai_horoscope_flow_logs。 +- 图面性能:仅静态 XML,无运行时开销;文件预计 40-80KB,生成一次即可。 + +## 目录结构 + +``` +project-root/ +└── docs/ + └── zt-closet-er.drawio # [NEW] 数据库 ER 图(draw.io 格式)。包含 4 个业务域分区容器、22 张表实体、20 条 1:N 关系连线、图例说明。mxGraph XML 根节点 mxfile/diagram/mxGraphModel,实体为 vertex,连线为 edge,坐标与样式按分区配色(用户域蓝、衣橱域绿、交易域橙、AI运营域紫)。 +``` \ No newline at end of file diff --git a/.codebuddy/plans/用户头像上传接口_eb17021f.md b/.codebuddy/plans/用户头像上传接口_eb17021f.md new file mode 100644 index 0000000..51c0cfc --- /dev/null +++ b/.codebuddy/plans/用户头像上传接口_eb17021f.md @@ -0,0 +1,133 @@ +--- +name: 用户头像上传接口 +overview: 新增服务端中转的头像上传接口 POST /api/v1/user/avatar/upload,复用现有衣物上传的图片校验与 COS 上传能力,校验通过后上传 COS 并将头像 key 写入 users.avatar,最后失效用户缓存。 +todos: + - id: avatar-service + content: 实现 UserService.AvatarUpload(校验、COS 上传、更新 avatar、失效缓存)与 buildUserInfoResponse 头像 URL 转换 + status: completed + - id: avatar-api + content: 新增头像上传 DTO 与 UserHandler.AvatarUpload,注册 POST /user/avatar/upload 路由 + status: completed + dependencies: + - avatar-service + - id: avatar-docs + content: 同步更新接口文档.md 与 README.md 的头像上传接口说明 + status: completed + dependencies: + - avatar-api +--- + +## 产品概述 + +为当前用户新增"头像文件上传"能力。此前后端只允许通过 `PUT /user/profile` 传入头像 URL 字符串,现需提供方案 A(服务端中转):客户端以 multipart 上传头像图片,后端完成校验、存储到 COS,并更新用户头像字段,返回可直接访问的头像 URL。 + +## 核心功能 + +- 新增鉴权接口 `POST /api/v1/user/avatar/upload`,multipart 字段名 `file` +- 后端校验上传文件:非空、扩展名白名单、大小上限、图片 magic bytes +- 上传成功后写入 COS,并将 COS 对象键保存到 `users.avatar` +- 更新成功后失效用户信息缓存,返回可访问的头像 URL 供前端立即展示 +- 用户信息查询接口对头像做兼容转换:历史 URL 直接透传,新存 COS key 自动转可访问 URL +- 头像上传不消耗衣物 COS 上传额度 + +## 技术栈 + +- 语言/框架:Go + Gin(沿用现有项目栈) +- 存储:腾讯云 COS(复用 `global.CosClient`) +- 缓存:Redis(复用 `redisdao.UserInfoDel` 失效用户缓存) +- 持久层:GORM(复用 `mysqldao.UserDao`) +- 工具函数:复用 `pkg/upload` 的文件校验、落盘、COS 上传、URL 生成能力 +- 无新增第三方依赖 + +## 实施方案 + +### 整体策略 + +仿照现有衣物上传 `ClothesService.Upload` 的同步链路,在用户模块新增头像上传能力:Handler 解析 multipart → Service 完成校验、落盘、上传 COS、更新数据库、失效缓存 → 返回头像 URL。 + +### 关键决策 + +1. **复用现有校验链路与错误码**:扩展名白名单(`global.UploadSetting.AllowedExtensions`)、大小上限(`global.UploadSetting.MaxSizeMB`)、magic bytes 校验,以及 `UploadFileEmpty` / `UploadFileExtNotAllow` / `UploadFileTooLarge` / `UploadFileSaveFailed` 错误码,保持与衣物上传一致。 +2. **avatar 存储 COS 对象键而非完整 URL**:与衣物 `processed_image` 存 key 的语义一致,读取时统一经 `upload.GetCosUrl` 转换;对历史通过 `PUT /user/profile` 传入的 http(s) URL 直接透传,保证向后兼容。 +3. **COS key 规则**:`{user.UserUUID}/{userID}/avatar/{随机名+原扩展名}`,复用 `FileGenName` 防冲突,且按用户维度隔离,避免跨用户覆盖。 +4. **不扣 COS 上传额度**:头像属基础资料,与衣物图片处理(抠图/分类)成本不同,不调用 `consumeCosQuota`。 +5. **上传后失效缓存**:调用 `redisdao.UserInfoDel(ctx, userID)`,使 `InfoGetBySelf` / `WxLogin` 下次回读最新头像。 + +### 数据流 + +```mermaid +sequenceDiagram + participant C as 客户端 + participant H as UserHandler.AvatarUpload + participant S as UserService.AvatarUpload + participant U as pkg/upload + participant COS as 腾讯云 COS + participant D as UserDao + participant R as Redis + + C->>H: POST /user/avatar/upload (multipart file) + H->>H: JWT 鉴权 + MaxBytesReader 限流 + H->>S: AvatarUpload(ctx, userID, fileHeader) + S->>S: 扩展名/大小/magic bytes 校验 + 读取字节 + S->>D: UserGetByID 获取 UserUUID + S->>U: FileGenName + FileSaveTmpBytes + S->>U: FileUploadCos(cosKey) + U->>COS: 上传对象 + S->>D: UserUpdateByID(avatar=cosKey) + S->>R: UserInfoDel 失效缓存 + S-->>H: 返回头像可访问 URL + H-->>C: 统一响应 +``` + +## 实现细节 + +### 目录结构 + +``` +project-root/ +├── internal/ +│ ├── dto/ +│ │ └── user.go # [MODIFY] 新增 UserAvatarUploadRequest(空)/ UserAvatarUploadResponse +│ ├── service/ +│ │ └── user.go # [MODIFY] 新增 AvatarUpload 方法;buildUserInfoResponse 头像 URL 转换;补充 imports +│ ├── routers/ +│ │ ├── router.go # [MODIFY] 注册 POST /user/avatar/upload +│ │ └── api/v1/ +│ │ └── user.go # [MODIFY] 新增 UserHandler.AvatarUpload;补充 imports +├── 接口文档.md # [MODIFY] 新增头像上传接口说明 +└── README.md # [MODIFY] 同步用户接口说明 +``` + +### 关键代码结构 + +``` +// internal/dto/user.go +type UserAvatarUploadRequest struct{} + +type UserAvatarUploadResponse struct { + Avatar string `json:"avatar"` // 头像可访问 URL +} +``` + +``` +// internal/service/user.go +func (s *UserService) AvatarUpload(ctx context.Context, userID int64, fileHeader *multipart.FileHeader) (*dto.UserAvatarUploadResponse, *errcode.Error) +``` + +`buildUserInfoResponse` 中头像转换逻辑: + +``` +avatar := user.Avatar +if avatar != "" && !strings.HasPrefix(avatar, "http://") && !strings.HasPrefix(avatar, "https://") { + avatar = upload.GetCosUrl(avatar) +} +``` + +### 执行要点 + +- **imports 补充**:`internal/service/user.go` 需新增 `io`、`mime/multipart`、`pkg/upload`;`internal/routers/api/v1/user.go` 需新增 `net/http`、`global`、`pkg/errcode`(若缺)。 +- **错误映射**:`FileSaveTmpBytes`、`FileUploadCos` 返回原生 error 时统一映射为 `errcode.UploadFileSaveFailed`;`UserGetByID` 未命中映射 `UserNotFound`,其他 DB 错误映射 `DatabaseError`;`UserUpdateByID` 失败映射 `UserUpdateFailed`。 +- **同步读取字节**:multipart 临时文件在请求返回后会被清理,必须在 Service 内同步 `io.ReadAll` 后再异步/后续处理(本方案全同步,无异步环节)。 +- **`buildUserInfoResponse` 影响面**:该函数同时被 `InfoGetBySelf` 与 `WxLogin` 使用,头像 URL 转换对两处一并生效,属预期行为,无需额外改动。 +- **缓存失效**:复用 `ProfileUpdate` 既有模式 `_ = redisdao.UserInfoDel(ctx, userID)`,失败仅忽略(best-effort),下次读取会回落 DB 回填。 +- **兼容性保障**:不修改 `PUT /user/profile` 的现有 avatar 传 URL 语义,避免破坏已有客户端;仅扩展用户信息读取时的 URL 转换。 \ No newline at end of file diff --git a/.codebuddy/plans/虚拟支付沙箱环境自动选择_bb981350.md b/.codebuddy/plans/虚拟支付沙箱环境自动选择_bb981350.md new file mode 100644 index 0000000..e8c7d8d --- /dev/null +++ b/.codebuddy/plans/虚拟支付沙箱环境自动选择_bb981350.md @@ -0,0 +1,124 @@ +--- +name: 虚拟支付沙箱环境自动选择 +overview: 为虚拟支付增加环境自动选择:保持 EnvAppKey/SandboxAppKey 双槽位配置,按「只填沙箱 key → env=1 沙箱;只填现网 key 或两把都填 → env=0 现网」的规则在客户端初始化时决定激活环境,并把 env 贯穿到道具同步、下单签名、订单记录与回调校验全链路。 +todos: + - id: wechat-env-select + content: 改造 pkg/wechat/virtualpay.go:新增 resolveEnvKey 与 client.env,调整初始化校验,道具 API 带 env,QueryOrder/RefundOrder/BuildPaymentSign 去 env 入参并新增 GetVirtualPayEnv + status: completed + - id: service-env-wire + content: 接入 internal/service/virtualpay.go:VirtualOrderCreate 改用 GetVirtualPayEnv 记录订单环境并适配签名调用 + status: completed + dependencies: + - wechat-env-select + - id: test-adapt + content: 适配 virtualpay_test.go 现有测试并新增 TestResolveEnvKey 四分支单测 + status: completed + dependencies: + - wechat-env-select + - id: sync-config-docs + content: 更新 config.yaml、config.example.yaml 与 README 中 WechatVirtualPay 的环境选择规则说明 + status: completed + dependencies: + - wechat-env-select + - id: verify-build + content: 执行 go build、go vet、go test 与 read_lints 全量验证 + status: completed + dependencies: + - service-env-wire + - test-adapt + - sync-config-docs +--- + +## 产品概述 + +为微信小程序虚拟支付(XPay)增加环境自动选择能力:配置保留双槽位(EnvAppKey 现网 / SandboxAppKey 沙箱),服务启动初始化时按规则自动判定激活环境,并把 env 贯穿到道具同步、下单签名、查单退款全链路。测试与上线切换只需修改配置文件,无需改代码。 + +## 核心功能 + +- 环境自动选择:只填沙箱 AppKey 时激活沙箱环境(env=1,测试不真实扣款);只填现网 AppKey 或两把都填时激活现网环境(env=0);两把都空时初始化报错、虚拟支付禁用 +- 全链路 env 贯穿:道具上传/发布、订单查询/退款、前端调起签名(signData 的 env 字段与 paySig)均使用激活环境的 key 与 env 值 +- 订单记录环境:虚拟支付订单的 xpay_env 字段记录下单时的激活环境,发货推送按此校验(沙箱推送不会误发现网订单) +- 环境切换零代码:切换测试/现网只需改配置后重启,启动时的全量道具同步会自动把商品发布到新激活的环境 + +## 技术栈 + +- 语言与框架:Go 1.27(复用现有项目栈,不改架构) +- 涉及模块:pkg/wechat/virtualpay.go(XPay 客户端)、internal/service/virtualpay.go(虚拟支付业务)、pkg/wechat/virtualpay_test.go(单元测试) +- 配置:configs/config.yaml、configs/config.example.yaml(仅更新注释) + +## 实现方案 + +### 环境选择规则(初始化时一次决定) + +抽成纯函数便于单测: + +``` +// resolveEnvKey 按配置决定激活环境: +// EnvAppKey 非空 -> 现网(env=0,两把都填时现网优先); +// EnvAppKey 空且 SandboxAppKey 非空 -> 沙箱(env=1); +// 两把都空 -> 返回错误。 +func resolveEnvKey(envAppKey, sandboxAppKey string) (appKey string, env int, err error) +``` + +`InitVirtualPayClient` 调用该函数得到激活 key 与 env:校验 OfferID 非空 + resolveEnvKey 成功;激活 key 存入 client 的 envAppKey 字段(保持 callXPay 签名逻辑不动),新增 `env int` 字段记录激活环境。日志输出当前激活环境,便于运维确认。 + +### env 贯穿链路 + +```mermaid +flowchart LR + A[config.yaml
EnvAppKey / SandboxAppKey] --> B[InitVirtualPayClient
resolveEnvKey] + B --> C[client.envAppKey = 激活key
client.env = 0/1] + C --> D[道具同步 API
body 带 env + 沙箱key签名] + C --> E[BuildPaymentSign
signData.env = client.env
paySig 用激活key] + C --> F[QueryOrder / RefundOrder
内部用 client.env] + E --> G[VirtualOrderCreate
订单 xpay_env = GetVirtualPayEnv] + G --> H[发货推送校验
推送 Env == 订单 xpay_env] +``` + +### 关键改动点 + +**pkg/wechat/virtualpay.go** + +- 新增 `resolveEnvKey` 纯函数(规则见上) +- `virtualPayClient` 增加 `env int` 字段;`InitVirtualPayClient` 改用 resolveEnvKey(原校验改为 OfferID 非空 + 至少一把 key) +- 道具 API(StartUploadGoods/QueryUploadGoods/StartPublishGoods/QueryPublishGoods)body 增加 `"env": c.env`(微信按 env+key 双校验,注释标注字段联调以官方文档为准) +- `QueryOrder`/`RefundOrder` 删除 env 入参(当前无 service 调用点,改动安全),内部使用 `c.env` +- `BuildPaymentSign` 删除 env 入参,signData 的 env 字段与 paySig 使用 `c.env` 与 `c.envAppKey`(即激活环境) +- 新增导出函数 `GetVirtualPayEnv() (int, error)` 供 service 层记录订单环境 + +**internal/service/virtualpay.go** + +- `VirtualOrderCreate`:`XPayEnv` 由写死的 `wechat.XPayEnvProd` 改为 `wechat.GetVirtualPayEnv()` 返回值;`BuildPaymentSign` 调用去掉 env 实参 +- `SyncVirtualGoods` 道具同步调用不变(env 已在 wechat 层内部带入) +- `handleGoodsDeliver` 的 Env 校验逻辑保持不动(订单 xpay_env 与推送 Env 匹配) + +**pkg/wechat/virtualpay_test.go** + +- 适配 `TestBuildPaymentSign`:client 构造改为含 env 字段、调用去掉 env 实参 +- 新增 `TestResolveEnvKey`:覆盖四个分支(只填现网、只填沙箱、两把都填、两把都空) + +**配置与文档** + +- `configs/config.yaml` 与 `configs/config.example.yaml`:WechatVirtualPay 段注释更新为环境选择规则(只填沙箱 key 走沙箱测试;只填现网 key 或两把都填走现网) +- `README.md` 配置表 WechatVirtualPay 行同步该规则 + +## 目录结构 + +``` +pkg/wechat/ +├── virtualpay.go # [MODIFY] resolveEnvKey 纯函数、client.env 字段、初始化调整、道具 API 带 env、QueryOrder/RefundOrder 去 env 入参、BuildPaymentSign 去 env 入参、GetVirtualPayEnv +└── virtualpay_test.go # [MODIFY] 适配现有签名测试 + 新增 TestResolveEnvKey 四分支单测 +internal/service/ +└── virtualpay.go # [MODIFY] VirtualOrderCreate 改用 GetVirtualPayEnv 记录订单环境 +configs/ +├── config.yaml # [MODIFY] WechatVirtualPay 段注释更新(不改已填值) +└── config.example.yaml # [MODIFY] WechatVirtualPay 段注释更新环境选择规则 +README.md # [MODIFY] 配置表 WechatVirtualPay 行说明同步 +``` + +## 实现注意事项 + +- 只改虚拟支付环境选择与 env 贯穿,不动支付架构、权益发放/回退、订单状态机、普通 JSAPI 支付 +- 环境切换后重启即可:P0-2 修复后的全量道具同步会自动把商品发布到新激活环境,无需额外迁移 +- 沙箱与现网的道具/订单/推送隔离由微信侧保证(按签名 key 与 env 双校验) +- 验证:go build ./...、go vet ./...、go test ./pkg/wechat/ 通过;read_lints 无新增错误 \ No newline at end of file diff --git a/.codebuddy/plans/衣柜分享-分享给我的_70d0547d.md b/.codebuddy/plans/衣柜分享-分享给我的_70d0547d.md new file mode 100644 index 0000000..7f0a403 --- /dev/null +++ b/.codebuddy/plans/衣柜分享-分享给我的_70d0547d.md @@ -0,0 +1,147 @@ +--- +name: 衣柜分享-分享给我的 +overview: 新增"分享给我的"能力:通过 closet_share_receivers 关联表记录登录用户访问分享详情的接收关系(一对多、幂等),并新增 GET /closet-share/received 列表接口,返回含分享者昵称/头像的分享列表。 +todos: + - id: add-receiver-migration-model + content: 新增 closet_share_receivers 迁移、ClosetShareReceiver 模型与 DAO(幂等 upsert、联表分页查询) + status: completed + - id: add-received-service + content: 扩展 DTO 与 UserGetByIDs,详情记录接收人并实现 ListReceived 服务 + status: completed + dependencies: + - add-receiver-migration-model + - id: add-received-handler-route + content: 新增 ReceivedList handler 并注册 /closet-share/received 路由 + status: completed + dependencies: + - add-received-service + - id: update-docs + content: 更新接口文档与 README 的衣柜分享模块说明 + status: completed + dependencies: + - add-received-handler-route +--- + +## 产品概述 + +为后端"衣柜分享"补齐"分享给我的"数据能力。当前 `closet_shares` 只记录分享发起者(`user_id`),没有接收人概念,`GET /closet-share` 仅返回"我发起的分享"。本次改造采用"访问即视为收到"的语义:登录用户(非分享属主)成功访问某个分享详情时,自动建立该用户与分享的接收关系,并提供"我收到的分享"分页列表接口。 + +## 核心功能 + +- **接收关系自动记录**:保持现有链接分享方式不变;登录用户首次访问他人分享详情且分享状态正常时,自动幂等记录为接收人,重复访问不产生重复记录,分享属主访问不记录。 +- **一对多接收人**:同一分享可被多个登录用户访问,每个接收人各生成一条关联记录,通过 `share_uuid + receiver_user_id` 联合唯一索引去重。 +- **"分享给我的"列表接口**:新增 `GET /closet-share/received`,按当前登录用户分页查询其收到的分享,返回分享基本信息、衣物数量、分享状态,并额外返回分享者昵称与头像(`owner_name` / `owner_avatar`),支持按状态筛选。 +- **接口兼容**:现有"我的分享"列表响应结构仅新增字段,不破坏既有字段;既有创建、详情、取消分享接口行为保持不变。 + +## 技术选型 + +- 语言/框架:Go + Gin(沿用现有项目) +- ORM:GORM(含 `soft_delete.DeletedAt` 软删除) +- 数据库:MySQL,迁移使用 `golang-migrate`(`go:embed *.sql` 自动打包,服务不自动执行迁移) +- 缓存:Redis(访问计数沿用现状,接收关系落 MySQL,保证可查询与持久化) + +## 实现方案 + +### 数据模型 + +新增关联表 `closet_share_receivers`,与现有表结构规范保持一致(含 `id`、`created_at`、`updated_at`、`deleted_at`、`created_by`、`updated_by`、`deleted_by`): + +- `share_uuid VARCHAR(64)`:分享 UUID +- `receiver_user_id BIGINT`:接收人用户 ID +- `UNIQUE KEY uk_closet_share_receiver (share_uuid, receiver_user_id)`:保证一个用户对同一分享只有一条记录 +- `KEY idx_closet_share_receivers_user (receiver_user_id)`:支撑"按接收人分页查询" + +接收关系写入采用幂等 upsert(唯一索引冲突时 `DO NOTHING`),避免并发访问重复插入。 + +### 接收关系记录时机 + +在 `ClosetShareService.Detail` 中,仅当以下条件全部满足时记录接收关系: + +1. 分享状态校验通过(未被取消/封禁/过期); +2. 衣物明细加载成功,详情能够正常返回; +3. 当前 `userID > 0` 且非分享属主。 + +记录为 best-effort:失败仅告警,不阻断详情返回,避免因关联表故障影响详情可用性。 + +### "分享给我的"列表查询 + +新增 `ClosetShareService.ListReceived`,通过 DAO 联表 `closet_share_receivers` 与 `closet_shares` 分页查询,避免 N+1: + +1. 联表分页取出分享记录(按接收记录 `id DESC` 排序,支持 `status` 过滤); +2. 复用 `ClosetShareItemListByShareUUIDs` 批量统计各分享衣物数量; +3. 新增 `UserDao.UserGetByIDs` 批量查询分享者昵称/头像,头像按现有 `upload.GetCosUrl` 规则转换; +4. 组装列表项返回。 + +### 路由兼容 + +在 `GET /closet-share/:uuid` 之前注册静态路由 `GET /closet-share/received`。gin 路由树对静态段优先于参数段,此顺序可确保 `/received` 不被 `/:uuid` 误匹配。 + +## 实施要点 + +- **性能**:列表查询一次联表取分享、两次批量查询(衣物明细 + 用户信息),时间复杂度与页大小线性相关;联合唯一索引 + `receiver_user_id` 普通索引保证写入去重与列表检索效率。 +- **幂等与并发**:接收关系写入用 `clause.OnConflict{DoNothing: true}`,并发首次访问不会报错或产生重复数据。 +- **日志**:复用 `slog.WarnContext` 记录接收关系写入失败,仅含 `share_uuid`、`user_id`、`error`,不记录衣物或用户敏感大字段;避免高频详情访问造成日志刷屏,仅记录异常。 +- **安全**:接收关系写入、列表查询均使用 GORM 参数绑定,防 SQL 注入;列表仅返回当前登录用户自身的接收记录,无越权读取面。 +- **爆炸半径控制**:不改动既有 `closet_shares` 表结构,不修改创建/详情/取消接口既有返回契约,仅新增字段与新接口;迁移通过独立命令执行,服务启动不受影响。 +- **兼容性**:`ClosetShareItemResponse` 新增 `OwnerName`、`OwnerAvatar` 字段,"我的分享"列表暂不填充(空字符串),"分享给我的"列表填充分享者信息。 + +## 架构设计 + +```mermaid +flowchart LR + A[登录用户访问 GET /closet-share/:uuid] --> B{状态校验通过且非属主} + B -->|是| C[UPSERT closet_share_receivers
唯一键幂等] + B -->|否| D[返回详情] + C --> D + + E[GET /closet-share/received] --> F[JOIN receivers + shares 分页] + F --> G[批量统计衣物数量] + F --> H[批量查询分享者信息] + G --> I[组装列表返回] + H --> I +``` + +## 目录结构 + +``` +/root/zt-closet-backend/ +├── pkg/migration/ +│ ├── 000018_create_closet_share_receivers.up.sql # [NEW] 创建 closet_share_receivers 表:标准字段 + share_uuid + receiver_user_id,联合唯一索引与 receiver_user_id 普通索引 +│ └── 000018_create_closet_share_receivers.down.sql # [NEW] DROP TABLE IF EXISTS closet_share_receivers +├── internal/mysqlmodel/ +│ └── closet.go # [MODIFY] 新增 ClosetShareReceiver 模型及 TableName,唯一索引标签与迁移一致 +├── internal/mysqldao/ +│ ├── closet.go # [MODIFY] 新增 ClosetShareReceiverDao:NewClosetShareReceiverDao、ClosetShareReceiverUpsert(DO NOTHING 幂等)、ClosetShareReceivedList(联表分页查询 shares) +│ └── user.go # [MODIFY] 新增 UserGetByIDs 批量查询,返回 []mysqlmodel.User,空 ids 直接返回空 +├── internal/dto/ +│ └── closet.go # [MODIFY] ClosetShareItemResponse 新增 OwnerName/OwnerAvatar;接收列表复用 ClosetShareListRequest 与 ShareListResponse +├── internal/service/ +│ └── closet.go # [MODIFY] ClosetShareService 新增 shareReceiverDao;构造器初始化;Detail 状态校验通过后 best-effort 记录接收人;新增 ListReceived 分页查询并批量填充分享者信息 +├── internal/routers/api/v1/ +│ └── closet.go # [MODIFY] ClosetShareHandler 新增 ReceivedList 方法,绑定 ClosetShareListRequest 并调用 svc.ListReceived +├── internal/routers/ +│ └── router.go # [MODIFY] 在 GET /closet-share/:uuid 前注册 auth.GET("/closet-share/received", closetHandler.ReceivedList) +├── 接口文档.md # [MODIFY] 衣柜分享模块新增 GET /closet-share/received 接口说明(请求参数、响应字段、错误码) +└── README.md # [MODIFY] 更新衣柜分享业务流程与接口列表,补充"访问即视为收到"与"分享给我的"说明 +``` + +## 关键代码结构 + +``` +// internal/mysqlmodel/closet.go +type ClosetShareReceiver struct { + Model + ShareUUID string `gorm:"column:share_uuid;uniqueIndex:uk_closet_share_receiver,priority:1"` + ReceiverUserID int64 `gorm:"column:receiver_user_id;uniqueIndex:uk_closet_share_receiver,priority:2"` +} + +// internal/mysqldao/closet.go +func (d *ClosetShareReceiverDao) ClosetShareReceiverUpsert(ctx context.Context, shareUUID string, receiverUserID int64) error +func (d *ClosetShareReceiverDao) ClosetShareReceivedList(ctx context.Context, receiverUserID int64, status int8, page, pageSize int) ([]mysqlmodel.ClosetShare, int64, error) + +// internal/mysqldao/user.go +func (d *UserDao) UserGetByIDs(ctx context.Context, ids []int64) ([]mysqlmodel.User, error) + +// internal/service/closet.go +func (s *ClosetShareService) ListReceived(ctx context.Context, userID int64, request *dto.ClosetShareListRequest) (*dto.ShareListResponse, *errcode.Error) +``` \ No newline at end of file diff --git a/.codebuddy/plans/补充_README_接口调用逻辑文档_747f2d39.md b/.codebuddy/plans/补充_README_接口调用逻辑文档_747f2d39.md new file mode 100644 index 0000000..fb83c9d --- /dev/null +++ b/.codebuddy/plans/补充_README_接口调用逻辑文档_747f2d39.md @@ -0,0 +1,70 @@ +--- +name: 补充 README 接口调用逻辑文档 +overview: 在现有 README 的 API 概览基础上,补充通用调用约定(鉴权/响应/分页/幂等/状态码映射)、全量接口入参出参字段表(基于 internal/dto)、核心业务调用流程时序(登录、衣物上传与 AI 处理轮询、AI 推荐、订单支付、点赞收藏),并附关键接口示例,使其他开发者可通过 README 确认调用逻辑。 +todos: + - id: extract-contracts + content: 使用 [subagent:code-explorer] 读取全部 dto 与 handler 文件,产出逐接口契约事实清单(绑定方式/请求响应字段/枚举) + status: completed + - id: write-conventions-details + content: 在 README 的 API 概览章节增补通用调用约定与 11 个模块的接口请求/响应字段详解表 + status: completed + dependencies: + - extract-contracts + - id: write-flows-examples + content: 增补 5 条核心业务调用流程(mermaid 时序图与步骤说明)及关键接口 curl 调用示例 + status: completed + dependencies: + - write-conventions-details + - id: proofread-readme + content: 全文校对:字段、枚举、错误码、路由与代码一致,检查 Markdown 与 mermaid 渲染正确 + status: completed + dependencies: + - write-flows-examples +--- + +## 用户需求 + +在上一轮已完成 README.md(含项目简介、核心功能、技术栈、目录结构、快速开始、配置说明、API 路由概览表、错误码分段表等章节)的基础上,进一步补充完善 README,使其他人(前端 / 联调开发者)仅凭 README 即可确认接口的调用逻辑: + +- 每个接口如何调用:方法、路径、请求参数(字段/类型/必填/说明)、响应字段 +- 关键业务的调用顺序与依赖关系 +- 统一调用约定:鉴权方式、响应结构、HTTP 状态码与业务错误码映射、分页、幂等 +- 关键接口的调用示例(curl + 响应 JSON) + +交付物:仅修改 `/root/zt-closet-backend/README.md`,在现有内容上增补,不重写已有章节,中文撰写;所有字段名、枚举值、默认值以代码为准,不虚构。 + +## 核心内容 + +1. **通用调用约定**:Bearer 鉴权(11000/11001/11002/11004 错误)、统一响应结构(成功/失败 JSON 示例)、HTTP 状态码与业务 code 映射(401/403/429/500)、分页约定(page/page_size,max 50)、幂等约定(点赞/收藏 Action 切换、AI 推荐保存防重)。 +2. **接口调用详解**:按 11 个业务模块(用户/额度/运势/衣物/穿搭/衣柜分享/点赞收藏/通知/商品/订单/AI 推荐)为全部 40+ 接口给出请求参数表与响应字段表,字段严格以 `internal/dto` 的 json tag、binding 与注释为准。 +3. **核心业务调用流程**:5 条流程(登录与 token 刷新、衣物上传到 AI 处理状态轮询、AI 推荐到保存为穿搭、订单创建到微信支付回调到查询、点赞/收藏切换),配 mermaid 时序图与编号步骤。 +4. **调用示例**:登录、衣物上传、状态查询、AI 推荐等关键接口的 curl 命令与响应 JSON。 + +## 撰写策略 + +### 事实来源与准确性保障 + +- 全部接口契约以 `internal/dto/`(13 个文件的 Request/Response 结构体:json tag、binding 约束、中文注释)与 `internal/routers/router.go`(路由注册、path 参数)为准。 +- 参数绑定方式(JSON body / GET form / path / multipart file)以 `internal/routers/api/v1/` 下 12 个 handler 文件为准;若 dto 与 handler 不一致,以 handler 与 router.go 为准。 +- 枚举与常量(衣物处理状态 1-5、点赞/收藏 Action 1/2、订单状态 1-5 等)以 `internal/service/constants.go` 为准。 + +### 文档结构(在现有 README 的 API 概览章节内增补) + +1. **通用调用约定**:鉴权头格式与失效表现、成功/失败响应 JSON 示例、HTTP 状态码映射表、分页约定、幂等约定。 +2. **接口调用详解**:每个模块小节先列路由清单,再逐接口给请求参数表与响应字段表,采用紧凑三列表格(字段/类型/说明),字段类型按 JSON 语义标注(string/int/int64/bool/object/array),必填性依据 binding 标签。 +3. **核心业务调用流程**:5 条流程各配一张 mermaid sequenceDiagram(参与方:小程序前端、后端、微信/推理服务/大模型),流程图下附编号步骤说明与关键注意点(如衣物上传后立即返回排队状态、前端需轮询 status 接口;订单支付回调为公开路由、注意验签)。 +4. **调用示例**:登录、衣物上传(multipart)、状态查询、AI 推荐等关键接口的 curl 示例与响应 JSON。 + +### 篇幅控制 + +- 复用已有路由总表与错误码表,不重复整表;详解部分只补充字段级信息。 +- 字段过多的响应(如衣物 15 维分类字段)用紧凑表格呈现;公共分页结构说明一次,列表接口仅标注内嵌 PageResponse。 +- 保持既有章节编号与顺序不变,仅在 API 概览内插入小节。 + +## 推荐使用的扩展 + +### SubAgent + +- **code-explorer** +- 用途:读取 `internal/dto/` 全部 13 个文件与 `internal/routers/api/v1/` 全部 12 个 handler 文件,提取每个接口的绑定方式(JSON body/form/path/multipart)、请求与响应字段(json tag、类型、binding、注释)、以及 `internal/service/constants.go` 中的相关枚举常量 +- 预期结果:输出一份逐接口的契约事实清单(模块/接口/方法/路径/参数绑定方式/请求字段表/响应字段表/枚举值),作为撰写 README 的唯一事实依据,确保无虚构字段 \ No newline at end of file diff --git a/.codebuddy/plans/补齐_mysqldao_数据访问层_a1ecd74f.md b/.codebuddy/plans/补齐_mysqldao_数据访问层_a1ecd74f.md new file mode 100644 index 0000000..b6a564d --- /dev/null +++ b/.codebuddy/plans/补齐_mysqldao_数据访问层_a1ecd74f.md @@ -0,0 +1,245 @@ +--- +name: 补齐 mysqldao 数据访问层 +overview: 根据 mysqlmodel 全部实体与 dto 暴露的业务接口,补齐 internal/mysqldao 下所有 DAO(单表 CRUD + 常用查询,含分页、软删除、原子扣减、幂等),沿用现有 user.go 风格。 +todos: + - id: fix-user-dao + content: 修正 user.go 统一 d.db 并移除 UserQuotaListByUserID + status: completed + - id: add-clothes-outfit-closet-dao + content: 新增 clothes.go、outfit.go、closet.go 三个 DAO 文件 + status: completed + - id: add-like-collect-goods-dao + content: 新增 like.go、collect.go、goods.go 三个 DAO 文件 + status: completed + - id: add-order-quota-dao + content: 新增 order.go、quota.go 两个 DAO 文件 + status: completed + dependencies: + - fix-user-dao + - id: add-horoscope-notification-audit-vip-dao + content: 新增 horoscope.go、notification.go、auditlog.go、vipflowlog.go 四个 DAO 文件 + status: completed + - id: verify-build + content: 运行 go build、go vet 与 lint 验证编译通过 + status: completed + dependencies: + - fix-user-dao + - add-clothes-outfit-closet-dao + - add-like-collect-goods-dao + - add-order-quota-dao + - add-horoscope-notification-audit-vip-dao +--- + +## 产品概述 + +根据现有业务实体(`internal/mysqlmodel`)与接口 DTO(`internal/dto`)推断出的业务逻辑,补齐 `internal/mysqldao` 数据访问层,为后续 service 层提供统一、可测试、可替换的 GORM DAO。 + +## 核心功能 + +- **覆盖全部业务实体**:为 User、Clothes、Outfit/OutfitItem、ClosetShare/ClosetShareItem、Like、Collect、Goods、Order/RefundFlowLog、Quota/UserDailyUsage/QuotaFlowLog、Horoscope/AI 流水日志、UserNotification、审核日志、VIPFlowLog 提供 DAO。 +- **统一 CRUD 与列表**:主键/UUID/业务编码查询、创建、条件更新、软删除,以及带总数返回的分页列表。 +- **业务一致性能力**:Like/Collect 幂等(查记录→更新或创建)、订单/退款回调条件更新防重、配额原子扣减与回退、每日使用计数原子自增、Horoscope 与 AI 流水的幂等查询。 +- **风格对齐**:沿用现有 `struct{db *gorm.DB} + NewXxxDao()` 模式,并将方法体统一改为 `d.db.WithContext(ctx)`(顺带修正 user.go 中直接使用 `global.MysqlEngine` 的写法)。 + +## 约束与边界 + +- 仅修改/新增 `internal/mysqldao/` 内文件,不实现 service、handler、routers。 +- DAO 不引入 `app`/`errcode`,错误统一返回 `error`(含 `gorm.ErrRecordNotFound`),由上层转换。 +- 全部使用 GORM 参数绑定防 SQL 注入,不拼接 SQL 字符串。 + +## 技术栈 + +- 语言:Go +- ORM:GORM v2(复用 `global.MysqlEngine *gorm.DB`) +- 数据模型:`internal/mysqlmodel`(`Model` 基类含 `ID int64` 主键、`CreatedAt/UpdatedAt int64`、`DeletedAt soft_delete.DeletedAt` 软删除) + +## 实现方案 + +### DAO 组织方式 + +沿用现有 `user.go` 模式:每个 DAO 为 `struct{ db *gorm.DB }`,提供 `NewXxxDao()` 构造函数注入 `global.MysqlEngine`,方法统一使用 `d.db.WithContext(ctx)`。一个文件承载一个或多个强相关 DAO(如 Outfit 与 OutfitItem 同文件)。 + +### 关键技术决策 + +1. **统一 `d.db`**:修正 user.go 直接调用 `global.MysqlEngine` 的写法,使 DAO 可替换、可测试。 +2. **软删除**:依赖 `Model.DeletedAt soft_delete.DeletedAt`,gorm 查询自动过滤已删除记录;删除统一走 `db.Delete(&model)` 软删,不硬删。 +3. **分页 List 约定**:DAO List 方法签名统一为 `(ctx, 筛选条件..., page, pageSize int) (list, total int64, err error)`,内部先 `Count(&total)` 再 `Offset((page-1)*pageSize).Limit(pageSize).Order("id DESC")`。 +4. **Like/Collect 幂等**:目标表 `TargetID` 为 int64 主键(UUID→ID 转换在 service 层);DAO 提供 `GetByTargetAndUser` 供上层判断存在则 `UpdateStatus`,不存在则 `Create`。 +5. **订单/退款回调幂等**:提供条件更新 `UpdateStatusByOrderNo(orderNo, fromStatus, toStatus)`,用 `WHERE order_no = ? AND order_status = ?`,以 `RowsAffected == 0` 判定重复回调或状态非法。 +6. **配额原子扣减**:`IncreaseUsed` 用 `Where("user_id = ? AND quota_type = ? AND used + ? <= total", ...).UpdateColumn("used", gorm.Expr("used + ?", n))`,以 RowsAffected 判断是否超额;`DecreaseUsed` 用 `Where("used >= ?", n)` 递减回退。 +7. **每日使用计数原子自增**:`UserDailyUsage` 按 `user_id + stat_date` 唯一,提供按日期查询 + 对应字段 `UpdateColumn(..., gorm.Expr("... + 1"))` 自增。 +8. **错误处理**:查询未命中返回 `gorm.ErrRecordNotFound`(`First` 直接返回),不吞错;Create/Update 直接透传 `.Error`。 + +## 目录结构 + +本次共涉及 13 个文件(1 个修改 + 12 个新增),全部位于 `internal/mysqldao/`: + +``` +internal/mysqldao/ +├── user.go # [MODIFY] UserDao 方法体统一 d.db;删除 UserQuotaListByUserID(迁移至 QuotaDao) +├── clothes.go # [NEW] ClothesDao +├── outfit.go # [NEW] OutfitDao + OutfitItemDao +├── closet.go # [NEW] ClosetShareDao + ClosetShareItemDao +├── like.go # [NEW] LikeDao +├── collect.go # [NEW] CollectDao +├── goods.go # [NEW] GoodsDao +├── order.go # [NEW] OrderDao + RefundFlowLogDao +├── quota.go # [NEW] QuotaDao + UserDailyUsageDao + QuotaFlowLogDao +├── horoscope.go # [NEW] HoroscopeDao + AIHoroscopeFlowLogDao + AIRecommendFlowLogDao +├── notification.go # [NEW] NotificationDao +├── auditlog.go # [NEW] ClosetAuditLogDao + OutfitAuditLogDao +└── vipflowlog.go # [NEW] VIPFlowLogDao +``` + +## 各 DAO 方法清单 + +### ClothesDao(clothes.go) + +- `ClothesCreate(ctx, *Clothes) error` +- `ClothesGetByUUID(ctx, uuid string) (*Clothes, error)` +- `ClothesGetByID(ctx, id int64) (*Clothes, error)` +- `ClothesGetByUUIDs(ctx, uuids []string) ([]Clothes, error)` +- `ClothesGetByIDs(ctx, ids []int64) ([]Clothes, error)` +- `ClothesListByUserID(ctx, userID int64, categoryMain, categorySub string, page, pageSize int) ([]Clothes, int64, error)`(大类/小类为空则不筛选) +- `ClothesUpdateByUUID(ctx, uuid string, updates map[string]any) error` +- `ClothesDeleteByUUID(ctx, uuid string) error`(软删) + +### OutfitDao + OutfitItemDao(outfit.go) + +- `OutfitCreate/GetByUUID/GetByID/GetByUUIDs` +- `OutfitListByUserID(ctx, userID int64, isPublic, source int8, page, pageSize int) ([]Outfit, int64, error)`(isPublic/source 为 0 时不筛选) +- `OutfitUpdateByUUID(ctx, uuid string, updates map[string]any) error` +- `OutfitUpdateIsPublic(ctx, uuid string, isPublic int8) error` +- `OutfitDeleteByUUID(ctx, uuid string) error` +- `OutfitItemCreateBatch(ctx, items []OutfitItem) error` +- `OutfitItemListByOutfitID(ctx, outfitID int64) ([]OutfitItem, error)` +- `OutfitItemListByOutfitIDs(ctx, outfitIDs []int64) ([]OutfitItem, error)` +- `OutfitItemDeleteByOutfitID(ctx, outfitID int64) error` + +### ClosetShareDao + ClosetShareItemDao(closet.go) + +- `ClosetShareCreate/GetByUUID` +- `ClosetShareListByUserID(ctx, userID int64, status int8, page, pageSize int) ([]ClosetShare, int64, error)` +- `ClosetShareUpdateStatus(ctx, shareUUID string, status int8) error`(取消/封禁) +- `ClosetShareItemCreateBatch/ListByShareUUID/ListByShareUUIDs/DeleteByShareUUID` + +### LikeDao(like.go) + +- `LikeCreate(ctx, *Like) error` +- `LikeGetByTargetAndUser(ctx, likeType int8, targetID, userID int64) (*Like, error)` +- `LikeUpdateStatus(ctx, id int64, status int8) error` +- `LikeCountByTarget(ctx, likeType int8, targetID int64) (int64, error)` +- `LikeListUserIDsByTarget(ctx, likeType int8, targetID int64) ([]int64, error)` + +### CollectDao(collect.go) + +- `CollectCreate/GetByTargetAndUser/UpdateStatus` +- `CollectCountByTarget(ctx, collectType int8, targetID int64) (int64, error)` +- `CollectListTargetIDsByUser(ctx, collectType int8, userID int64) ([]int64, error)` + +### GoodsDao(goods.go) + +- `GoodsGetByCode(ctx, code string) (*Goods, error)` +- `GoodsGetByID(ctx, id int64) (*Goods, error)` +- `GoodsList(ctx, goodsType int8, page, pageSize int) ([]Goods, int64, error)`(仅 `status = 1` 上架;goodsType 为 0 时不筛选) + +### OrderDao + RefundFlowLogDao(order.go) + +- `OrderCreate/GetByID/GetByOrderNo` +- `OrderListByUserID(ctx, userID int64, status int8, page, pageSize int) ([]Order, int64, error)` +- `OrderUpdateByOrderNo(ctx, orderNo string, updates map[string]any) error` +- `OrderUpdateStatusByOrderNo(ctx, orderNo string, fromStatus, toStatus int8) (bool, error)`(条件更新,RowsAffected 判重) +- `RefundFlowLogCreate/GetByRefundNo/GetByOrderNo` +- `RefundFlowLogUpdateStatus(ctx, refundNo string, updates map[string]any) error` + +### QuotaDao + UserDailyUsageDao + QuotaFlowLogDao(quota.go) + +- `QuotaListByUserID(ctx, userID int64) ([]Quota, error)`(自 user.go 迁移) +- `QuotaGetByUserIDAndType(ctx, userID int64, quotaType int8) (*Quota, error)` +- `QuotaCreate(ctx, *Quota) error` +- `QuotaIncreaseUsed(ctx, userID int64, quotaType int8, n int64) (bool, error)`(原子扣减,超额返回 false) +- `QuotaDecreaseUsed(ctx, userID int64, quotaType int8, n int64) error`(回退) +- `UserDailyUsageGetByUserIDAndDate/Create` +- `UserDailyUsageIncrementAI/IncrementOutfitShare/IncrementClosetShare` +- `QuotaFlowLogCreate/ListByUserID(userID, quotaType, page, pageSize)` + +### HoroscopeDao + AI 流水(horoscope.go) + +- `HoroscopeGetByZodiacAndDate(ctx, zodiac string, statDate int64) (*Horoscope, error)` +- `HoroscopeCreate(ctx, *Horoscope) error` +- `AIHoroscopeFlowLogGetByRequestID/Create` +- `AIRecommendFlowLogGetByRequestID/Create` + +### NotificationDao(notification.go) + +- `NotificationCreate/GetByUUID` +- `NotificationListByUserID(ctx, userID int64, isRead int8, page, pageSize int) ([]UserNotification, int64, error)` +- `NotificationUpdateReadByUUID(ctx, uuid string) error` +- `NotificationCountUnread(ctx, userID int64) (int64, error)` + +### 审核日志(auditlog.go) + +- `ClosetAuditLogCreate/ListByTargetID` +- `OutfitAuditLogCreate/ListByTargetID` + +### VIPFlowLogDao(vipflowlog.go) + +- `VIPFlowLogCreate` +- `VIPFlowLogListByUserID(ctx, userID int64, page, pageSize int) ([]VIPFlowLog, int64, error)` + +## 关键代码结构 + +### DAO 构造与查询统一模式 + +``` +type XxxDao struct{ db *gorm.DB } + +func NewXxxDao() *XxxDao { return &XxxDao{db: global.MysqlEngine} } + +func (d *XxxDao) XxxGetByID(ctx context.Context, id int64) (*mysqlmodel.Xxx, error) { + var m mysqlmodel.Xxx + if err := d.db.WithContext(ctx).First(&m, id).Error; err != nil { + return nil, err + } + return &m, nil +} +``` + +### 分页 List 模式 + +``` +func (d *XxxDao) XxxList(ctx context.Context, page, pageSize int) ([]mysqlmodel.Xxx, int64, error) { + var list []mysqlmodel.Xxx + var total int64 + q := d.db.WithContext(ctx).Model(&mysqlmodel.Xxx{}) + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error + return list, total, err +} +``` + +### 原子扣减 / 条件更新模式 + +``` +// 配额原子扣减:超额时 RowsAffected 为 0 +res := d.db.WithContext(ctx).Model(&mysqlmodel.Quota{}). + Where("user_id = ? AND quota_type = ? AND used + ? <= total", userID, quotaType, n). + UpdateColumn("used", gorm.Expr("used + ?", n)) +if res.Error != nil { return false, res.Error } +return res.RowsAffected > 0, nil + +// 订单状态条件流转:防重复回调 +res := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}). + Where("order_no = ? AND order_status = ?", orderNo, fromStatus). + Update("order_status", toStatus) +return res.RowsAffected > 0, res.Error +``` + +## 实施要点 + +- **影响范围可控**:仅新增/修改 `internal/mysqldao/`,不触碰 `mysqlmodel`、`dto`、`redisdao` 及其他模块;对外仅移除 user.go 的 `UserQuotaListByUserID`(当前无调用方)。 +- **性能**:列表查询采用先 Count 再分页的单次复用 Query 链;批量查询 items 使用 `WHERE id IN (?)` 一次取回,避免 N+1;无额外开销。 +- **安全**:所有条件使用 `?` 占位符绑定,不拼接 SQL;软删除交由 gorm 处理。 +- **兼容性**:`gorm.ErrRecordNotFound` 原样返回,供 service 层用 `errors.Is` 判断;本次不引入新依赖。 \ No newline at end of file diff --git a/.codebuddy/plans/调整_COS_图片存储路径结构_5eea0fe3.md b/.codebuddy/plans/调整_COS_图片存储路径结构_5eea0fe3.md new file mode 100644 index 0000000..64094db --- /dev/null +++ b/.codebuddy/plans/调整_COS_图片存储路径结构_5eea0fe3.md @@ -0,0 +1,61 @@ +--- +name: 调整 COS 图片存储路径结构 +overview: 将 COS 图片对象键从 {userID}/{uuid.png} 改为 {衣物UUID}/{userID}/{uuid.png}(uuid/userid/图片 三层结构),同步调整临时文件保存目录保持一致。 +todos: + - id: adjust-cos-path + content: 修改 upload.go 的 FileSaveTmpBytes 签名(userID 改为 subDir)与 clothes.go 的 processImageAsync(subDir/cosKey 三层结构) + status: completed + - id: verify-build + content: 执行 go build ./... 验证编译通过 + status: completed + dependencies: + - adjust-cos-path +--- + +## 用户需求 + +修改 COS 图片的存储位置,将当前的对象键与本地临时目录结构从 `{userID}/{uuid.png}` 两层改为 `{衣物UUID}/{userID}/{uuid.png}` 三层(即用户表述的「uuid/userid/图片」)。 + +## 核心变更 + +- 抠图结果图片在 COS 上的对象键、以及后端本地临时文件目录,统一调整为 `{衣物UUID}/{userID}/{图片文件名}`。 +- 衣物详情返回的 `processed_image` 字段仍存该 COS 对象键,前端获取图片 URL 的链路不变。 + +## 影响范围 + +- 仅涉及图片上传落盘与 COS 对象键拼接,不影响上传/抠图/分类/额度等其它业务逻辑。 + +## 技术方案 + +### 现状 + +- `internal/service/clothes.go` 的 `processImageAsync`(约 137-145 行)当前生成: +- `processedName := genUUID() + ".png"` +- `cosKey := fmt.Sprintf("%d/%s", userID, processedName)`,即 `{userID}/{uuid.png}`。 +- `pkg/upload/upload.go` 的 `FileSaveTmpBytes(ctx, data, fileName, userID)` 当前把临时文件保存到 `TmpSavePath/{userID}/{fileName}`。 +- `FileUploadCos(ctx, cosKey)` 以同一个 `cosKey` 作为本地相对路径与 COS 对象键,因此只要 `cosKey` 结构变了,本地读取与 COS 上传会同步适配。 + +### 修改点 + +#### 1. `pkg/upload/upload.go` — `FileSaveTmpBytes` + +- 将第 4 个参数 `userID int64` 改为 `subDir string`。 +- 目录拼接由 `filepath.Join(TmpSavePath, fmt.Sprint(userID))` 改为 `filepath.Join(TmpSavePath, subDir)`。 +- 函数职责从「按 userID 建目录」泛化为「按调用方传入的子目录建目录」,保持通用。 + +#### 2. `internal/service/clothes.go` — `processImageAsync` + +- 新增 `subDir := fmt.Sprintf("%s/%d", clothesUUID, userID)`。 +- 保存调用改为 `upload.FileSaveTmpBytes(ctx, processedData, processedName, subDir)`。 +- `cosKey := fmt.Sprintf("%s/%s", subDir, processedName)`,最终结构为 `{clothesUUID}/{userID}/{uuid.png}`。 +- 同步更新该处「对象键含 userID 前缀」的注释,改为三层结构说明。 + +### 关键设计 + +- `FileUploadCos` 与 `GetCosUrl` 的签名、逻辑均不变,它们以 `cosKey` 为输入自动适配新结构,改动面最小。 +- `FileSaveTmpBytes` 仅 `clothes.go` 一处调用点,修改签名无连锁影响。 + +### 注意事项 + +- 旧数据兼容:历史 `processed_image` 中存的是旧两层 key,新代码下 `GetCosUrl` 会拼出 COS 上不存在的对象路径。联调阶段建议清库重传;如需兼容旧数据,需另做数据迁移,本次不处理。 +- 验证:改动后执行 `go build ./...` 确保编译通过。 \ No newline at end of file diff --git a/.codebuddy/plans/通知列表补充_msg_type_筛选_9998d1d5.md b/.codebuddy/plans/通知列表补充_msg_type_筛选_9998d1d5.md new file mode 100644 index 0000000..1dea9d6 --- /dev/null +++ b/.codebuddy/plans/通知列表补充_msg_type_筛选_9998d1d5.md @@ -0,0 +1,86 @@ +--- +name: 通知列表补充 msg_type 筛选 +overview: 为通知列表接口补充 msg_type 筛选字段(1=点赞 2=订单 3=系统),使 Stitch.md 通知页的「全部/点赞/订单/系统」分类筛选可用,并同步接口文档。 +todos: + - id: add-msgtype-filter + content: 修改通知 DTO、DAO、Service 三层,增加 msg_type 筛选参数并透传 + status: completed + - id: update-api-doc-msgtype + content: 更新接口文档 GET /notification 的 msg_type 参数说明与错误码描述 + status: completed + dependencies: + - add-msgtype-filter + - id: verify-build + content: 执行 go build ./... 验证编译通过 + status: completed + dependencies: + - add-msgtype-filter +--- + +## 用户需求 + +后端通知列表接口 `GET /notification` 目前仅支持 `is_read`(1=未读 2=已读)筛选,缺少 `msg_type`(1=点赞 2=订单 3=系统)筛选字段。而 Stitch.md 通知页已设计「全部、点赞、订单、系统」筛选标签,前后端能力不一致。需为后端补充 `msg_type` 筛选能力,使前端可按通知类型过滤。 + +## 核心功能 + +- `NotificationListRequest` 新增 `msg_type` 查询参数(可选,`oneof=1 2 3`,不传表示全部),与现有 `is_read` 可组合筛选。 +- `GET /notification` 支持按 `msg_type` 过滤点赞/订单/系统通知,同时保持与 `is_read` 组合使用。 +- 同步更新接口文档,补充 `msg_type` 参数说明。 + +## 约束 + +- 沿用现有 `is_read` 的筛选风格(`omitempty` + `oneof`,0 表示不筛选)。 +- 仅修改通知相关的 DTO、DAO、Service 三个 Go 文件与接口文档,不改动其它业务逻辑。 +- 改动后需通过 `go build ./...` 编译验证。 + +## 技术栈 + +- 语言:Go 1.27 +- 框架:Gin + GORM(复用现有通知模块实现) +- 分层:handler → service → dao,沿现有 `is_read` 筛选链路扩展 + +## 实现方式 + +在现有通知列表筛选链路上增加 `msg_type` 维度,三层逐层透传: + +1. **DTO 层**(`internal/dto/notification.go`):`NotificationListRequest` 增加字段 + +``` +MsgType int8 `form:"msg_type" binding:"omitempty,oneof=1 2 3"` // 1=点赞 2=订单 3=系统,不传=全部 +``` + +2. **DAO 层**(`internal/mysqldao/notification.go`):`NotificationListByUserID` 增加 `msgType int8` 参数,在现有 `is_read` 过滤后追加: + +``` +if msgType != 0 { +q = q.Where("msg_type = ?", msgType) +} +``` + +3. **Service 层**(`internal/service/notification.go`):`List` 调用改为 + +``` +s.notificationDao.NotificationListByUserID(ctx, userID, request.IsRead, request.MsgType, page, pageSize) +``` + +4. **文档层**(`接口文档.md`):`GET /notification` 请求参数表新增 `msg_type` 行,并说明 `oneof=1 2 3`、不传表示全部;错误码 `10000` 参数错误说明同步。 + +## 目录结构 + +``` +zt-closet-backend/ +├── internal/ +│ ├── dto/ +│ │ └── notification.go # [MODIFY] NotificationListRequest 新增 MsgType 字段 +│ ├── mysqldao/ +│ │ └── notification.go # [MODIFY] NotificationListByUserID 增加 msgType 参数与过滤 +│ └── service/ +│ └── notification.go # [MODIFY] List 透传 request.MsgType +└── 接口文档.md # [MODIFY] GET /notification 补充 msg_type 参数说明 +``` + +## 实现注意 + +- 参数校验用 `binding:"omitempty,oneof=1 2 3"`,与 `is_read` 的 `omitempty,oneof=1 2` 风格一致;传 0 或不传均表示不筛选。 +- `msg_type` 与 `is_read` 为并列条件,可组合使用(如「未读的点赞通知」)。 +- 通知类型常量沿用 `NotificationTypeLike=1`、`NotificationTypeOrder=2`、`NotificationTypeSystem=3`,不在 DAO 层引入魔法数字依赖 service 常量(DAO 直接用 `int8` 参数比较)。 \ No newline at end of file diff --git a/.codebuddy/plans/配置全面去环境变量化_3cc28783.md b/.codebuddy/plans/配置全面去环境变量化_3cc28783.md new file mode 100644 index 0000000..4e4c89f --- /dev/null +++ b/.codebuddy/plans/配置全面去环境变量化_3cc28783.md @@ -0,0 +1,83 @@ +--- +name: 配置全面去环境变量化 +overview: 移除应用配置的环境变量注入机制(viper AutomaticEnv),将所有配置统一收敛到 configs/config.yaml 直接配置,并更新所有相关注释与校验逻辑。 +todos: + - id: remove-viper-env + content: 删除 pkg/setting/setting.go 中 SetEnvPrefix、SetEnvKeyReplacer、AutomaticEnv 三行及 strings import,更新函数注释为仅从 config 读取 + status: completed + - id: update-config-comments + content: 将 configs/config.yaml 中 13 处环境变量注入注释统一改为直接填写说明,JWT 密钥处标注必填 + status: completed + - id: global-check-verify + content: 执行 go build、go vet、lint 检查,全局搜索确认无 os.Getenv、AutomaticEnv、ZT_CLOSET_ 残留 + status: completed + dependencies: + - remove-viper-env + - update-config-comments +--- + +## 产品概述 + +彻底移除后端配置的环境变量注入机制,所有配置一律从 `configs/config.yaml` 配置文件读取,环境变量不再参与任何配置项的赋值或覆盖。 + +## 核心功能 + +- **禁用 viper 环境变量覆盖**:删除 `pkg/setting/setting.go` 中 `SetEnvPrefix`、`SetEnvKeyReplacer`、`AutomaticEnv` 三处环境变量绑定,配置仅来自 `configs/config.yaml`。 +- **更新配置文件说明**:将 `configs/config.yaml` 中 13 处「敏感信息通过环境变量 ZT_CLOSET_XXX 注入」注释统一改为「直接在此填写」说明,并在 JWT 密钥处标注必填要求。 +- **全局清理与复查**:确保 Go 代码与配置注释中无 `os.Getenv`、`AutomaticEnv`、`ZT_CLOSET_` 残留引用(第三方 Python 推理服务的库级环境变量除外)。 +- **保持现有校验**:`main.go` 中 JWT SecretKey 为空的启动失败校验继续生效,提醒用户必须在配置文件中填写密钥才能启动。 + +## 技术栈 + +- 语言:Go +- 配置库:spf13/viper(保留 YAML 文件读取,移除环境变量绑定) +- 配置文件:`configs/config.yaml`(已被 .gitignore 忽略,不入库) + +## 实现方案 + +### 总体策略 + +环境变量注入在 Go 侧仅存在于 `pkg/setting/setting.go` 的 `NewSetting` 中,且全仓库已无 `os.Getenv` 调用(CORS 中间件此前已改为读取 `global.ServerSetting.Domain`)。因此本次改动为「单点删除 + 注释更新 + 全局复查」,不引入新机制、不影响其余 viper 读取逻辑。 + +### 核心数据流(改造后) + +``` +启动 main.go init -> setting.NewSetting() + -> viper 读取 configs/config.yaml(唯一来源) + -> ReadSection 逐节解析到 global.*Setting +``` + +环境变量不再参与任何环节。 + +### 模块改动 + +1. **pkg/setting/setting.go** + +- 删除 `vp.SetEnvPrefix("ZT_CLOSET")`、`vp.SetEnvKeyReplacer(...)`、`vp.AutomaticEnv()` 三行。 +- 更新 `NewSetting` 顶部注释为「仅从 configs/config.yaml 读取配置,不支持环境变量覆盖」。 +- 删除仅被 `strings.NewReplacer` 使用的 `strings` import(不删会导致 unused import 编译错误)。 +- 保留 AddConfigPath / SetConfigName / SetConfigType / ReadInConfig 逻辑。 + +2. **configs/config.yaml**(13 处注释统一改写,值保持空串待用户填写) + +- Mysql.Password、Redis.Password、Cos.CosSecretID、Cos.CosSecretKey、Cos.CdnDownloadSecretKey、Tokenhub.APIKey、WechatMini.AppSecret:注释改为「# 直接在此填写(config.yaml 已被 .gitignore 忽略,不会提交到仓库)」。 +- JWT.SecretKey:同上,并追加「必填,为空则启动失败」。 +- WechatPay.AppID / MchID / APIv3Key / SerialNo / NotifyURL / RefundNotifyURL:注释改为「# 直接在此填写(config.yaml 已被 .gitignore 忽略,不会提交到仓库)」。 + +3. **configs/config.example.yaml**:已确认为纯占位符示例、无环境变量表述,保持不动(该文件会入库,绝不可填真实密钥)。 +4. **main.go**:启动校验逻辑(JWT.SecretKey 非空)无需改动,移除环境变量后仍有效。 + +### 范围边界 + +- `thirdparty/matting_server/main.py`、`thirdparty/classification_server/main.py` 中的环境变量是 huggingface/transformers 库运行要求(必须在 import 前设置),不属于本应用配置体系,本次不改动。 +- `README.md` 为空文件,不涉及。 + +### 验证要点 + +- `go build ./...`、`go vet ./...`、`read_lints` 无新增错误。 +- 全局搜索确认 Go 代码与配置文件中无残留 `os.Getenv`、`AutomaticEnv`、`ZT_CLOSET_` 引用(thirdparty Python 除外)。 + +### 安全说明 + +- `configs/config.yaml` 已被 `.gitignore` 忽略,敏感信息写入不会入库;提醒用户注意本地文件权限与备份安全。 +- `configs/config.example.yaml` 会随代码入库,必须保持占位符示例值。 \ No newline at end of file diff --git a/.codebuddy/plans/配额流水事务一致性修复_9574ec66.md b/.codebuddy/plans/配额流水事务一致性修复_9574ec66.md new file mode 100644 index 0000000..db57bb6 --- /dev/null +++ b/.codebuddy/plans/配额流水事务一致性修复_9574ec66.md @@ -0,0 +1,126 @@ +--- +name: 配额流水事务一致性修复 +overview: 按方案 A 将「配额数值变更」与「写配额流水」合并到同一数据库事务,消除 best-effort 静默丢流水导致的账实不符(配额泄漏)风险,并全面排查同类问题、为其余 best-effort 写操作补齐日志。 +todos: + - id: dao-transactional-methods + content: 在 internal/mysqldao/quota.go 新增 QuotaConsumeWithFlow、QuotaRefundWithFlow、QuotaInitWithFlow 三个事务方法,并为 QuotaFlowLog 模型补唯一索引 tag + status: completed + - id: service-consume-refund + content: 改造 internal/service/service.go 的 consumeQuota 与 refundQuota 调用新事务方法,删除分离的 writeQuotaFlow 调用及函数 + status: completed + dependencies: + - dao-transactional-methods + - id: clothes-delete-transaction + content: 将 internal/service/clothes.go DeleteByUUID 的流水写入移入既有事务闭包,消除事务外写流水窗口 + status: completed + dependencies: + - dao-transactional-methods + - id: ensure-default-quota-transaction + content: 将 internal/service/quota.go ensureDefaultQuota 改用 QuotaInitWithFlow 实现并发安全幂等初始化 + status: completed + dependencies: + - dao-transactional-methods + - id: best-effort-logging + content: 为 airecommend.go、outfit.go、closet.go、notification.go、service.go recordDailyUsage、user.go 等保留的 best-effort 写操作补失败日志 + status: completed + - id: build-verify + content: 执行 go build、go vet 与 lint 检查,回归核对所有 consumeQuota/refundQuota 调用点返回语义 + status: completed + dependencies: + - service-consume-refund + - clothes-delete-transaction + - ensure-default-quota-transaction + - best-effort-logging +--- + +## 产品概述 + +修复配额流水与配额数值不一致导致的「配额泄漏」问题。核心思路(方案 A):将「扣减/回退配额」与「写配额流水」合并进同一数据库事务,保证数值变更与流水记录强一致;同时全面排查所有同类位置并补齐失败日志。 + +## 核心功能 + +- **事务化扣减(consume)**:扣减 used 与写流水在同一事务,流水写失败则整体回滚,返回数据库错误,杜绝「额度已扣但查不到流水」。 +- **事务化回退(refund)**:回退 used 与写流水同事务,保持「已用量不足则不回退」的现有语义,并保证流水一致性。 +- **事务化初始化(ensureDefaultQuota)**:新用户默认额度创建与流水写入同事务,利用 `uk_user_quota` 唯一索引实现并发安全、幂等初始化。 +- **衣物删除回退入事务**:`ClothesDeleteByUUID` 中条件回退 used 与流水写入移入同一事务闭包,消除事务外写流水的丢失窗口。 +- **全面排查补日志**:对所有保留的 best-effort 写操作(AI 推荐流水、审核日志、每日计数、通知、缓存等)失败时输出 `slog` 日志,保证可观测性。 +- **幂等索引自文档化**:为 `QuotaFlowLog` 模型补 `(order_no, quota_type, change_type)` 联合唯一索引 tag(不改动数据库,仅声明)。 + +## 行为变化说明 + +流水表故障时,配额消耗/回退将整体失败并返回 `DatabaseError`(用户需重试),这是方案 A 的预期代价,换来账实一致。 + +## 技术栈 + +- 语言:Go +- Web 框架:Gin +- ORM:GORM(事务、`clause.OnConflict`、`clause.Locking`) +- 数据库:MySQL(唯一索引兜底幂等) +- 日志:`log/slog`(复用现有 `slog.ErrorContext/WarnContext` 模式) + +## 实现方案 + +### 总体策略 + +参照项目内已正确的 `QuotaGrantIdempotent` / `QuotaRevokeIdempotent` 事务模式(`internal/mysqldao/quota.go`),在 DAO 层新增三个「事务内条件更新 + 回读 + 写流水」方法,Service 层改为调用事务方法,删除分离的 best-effort 写流水路径。 + +### 核心数据流 + +```mermaid +flowchart LR + A[业务调用 consumeQuota/refundQuota] --> B[构造 QuotaFlowLog] + B --> C[DAO 事务开始] + C --> D[条件更新 used 原子增减] + D --> E{RowsAffected > 0?} + E -- 否 --> F[返回 false/nil 额度不足或已用量不足] + E -- 是 --> G[事务内回读 total/used 填 Old/NewValue] + G --> H[事务内写入流水] + H --> I{写流水失败?} + I -- 是 --> J[整个事务回滚 数值变更撤销 返回错误] + I -- 否 --> K[事务提交 数值与流水强一致] +``` + +### 关键代码结构 + +DAO 层新增方法(置于 `internal/mysqldao/quota.go`): + +``` +// QuotaConsumeWithFlow 事务内扣减配额并写流水。 +// 条件更新失败(额度不足)返回 (false, nil);流水写入失败整体回滚并返回 error。 +func (d *QuotaDao) QuotaConsumeWithFlow(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) (bool, error) + +// QuotaRefundWithFlow 事务内回退配额并写流水。 +// 条件更新失败(已用量不足/重复回退)返回 (false, nil);流水失败整体回滚并返回 error。 +func (d *QuotaDao) QuotaRefundWithFlow(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) (bool, error) + +// QuotaInitWithFlow 事务内初始化配额并写流水。 +// 依赖 uk_user_quota 唯一索引(OnConflict DoNothing),已存在则跳过返回 nil。 +func (d *QuotaDao) QuotaInitWithFlow(ctx context.Context, userID int64, quotaType int8, total int64, flow *mysqlmodel.QuotaFlowLog) error +``` + +事务内部模式(仿 `QuotaGrantIdempotent`): + +1. `tx.Model(&Quota{}).Where("user_id=? AND quota_type=? AND used+?<=total").UpdateColumn("used", gorm.Expr("used + ?", n))`(消费)或 `WHERE used >= ?`(回退);`RowsAffected==0` 直接返回 `(false, nil)`。 +2. `tx.Where(...).First(&cur)` 回读真实值,`remain := cur.Total - cur.Used`,填 `flow.OldValue/NewValue`。 +3. `tx.Create(flow)`,任何错误返回导致整体回滚。 + +### 模块改动 + +- **`internal/mysqldao/quota.go`(核心)**:新增上述三个事务方法;保留 `QuotaIncreaseUsed`/`QuotaDecreaseUsed`(供 `clothes.go` 事务内或未来复用)。 +- **`internal/service/service.go`**:`consumeQuota`/`refundQuota` 改为构造 `flow`(含 `QuotaFlowUUID`、`ChangeType`、`OrderNo`、`Reason` 等)后调用事务方法;`false` 分支保持原语义(`QuotaInsufficient` / 告警后返回 nil);删除对 `writeQuotaFlow` 的分离调用。`writeQuotaFlow` 在所有调用点迁移完毕后删除。 +- **`internal/service/clothes.go`**:`DeleteByUUID` 将流水写入移入事务闭包——条件更新成功后,在同一 `tx` 内回读 `Quota` 并 `tx.Create(flow)`,删除事务外的 `refunded` 后置写流水逻辑(`refunded` 变量可保留供逻辑判断)。 +- **`internal/service/quota.go`**:`ensureDefaultQuota` 改为逐类型调用 `QuotaInitWithFlow`(`OnConflict DoNothing`),移除先查后插的竞态窗口和分离写流水。 +- **`internal/mysqlmodel/quotaflowlog.go`**:为 `OrderNo`、`QuotaType`、`ChangeType` 字段补 `uniqueIndex` tag 组合(仅自文档化;需同步提示用户在数据库确认该索引真实存在)。 +- **P1 补日志**:`internal/service/airecommend.go`(`writeFlowLog` 与成功流水写入)、`internal/service/outfit.go`(`OutfitAuditLogCreate`)、`internal/service/closet.go`(`ClosetAuditLogCreate`)、`internal/service/service.go`(`recordDailyUsage`)、`internal/service/notification.go`(`sendNotification` 的 `NotificationCreate` 失败分支)、`internal/service/user.go`(`ensureDefaultQuota` 调用处)失败时补 `slog.WarnContext/ErrorContext`(带 user_id、quota_type、error 等关键字段)。 + +### 性能与可靠性 + +- 每次消耗/回退新增一次回读 + 一次流水插入,均在同一短事务内完成;与现有 `grantQuota`/`revokeQuota` 事务模式开销一致,无长事务风险。 +- 保留原子条件更新语义,避免「先查后改」的并发超扣。 +- 日志不打印敏感数据(无 PII/密钥),仅记录业务 ID 与错误信息,避免日志轰炸(这些路径非高频热点)。 + +### 验证要点 + +- 编译与静态检查:`go build ./...`、`go vet ./...`、`read_lints` 无新增错误。 +- 语义回归核对:`consumeQuota` 调用方(`outfit.go`、`closet.go`、`airecommend.go`、`clothes.go`)仍按 `QuotaInsufficient`/`DatabaseError` 分支处理;`refundQuota` 调用方的 `_ =` 忽略行为不受影响。 +- 数据库检查项:确认 `quota_flow_logs` 存在 `(order_no, quota_type, change_type)` 联合唯一索引(幂等兜底前提)。 \ No newline at end of file diff --git a/.codebuddy/plans/额度体系改造_每日额度与VIP有效期_9b667d2b(未完成).md b/.codebuddy/plans/额度体系改造_每日额度与VIP有效期_9b667d2b(未完成).md new file mode 100644 index 0000000..f0a9e67 --- /dev/null +++ b/.codebuddy/plans/额度体系改造_每日额度与VIP有效期_9b667d2b(未完成).md @@ -0,0 +1,184 @@ +--- +name: 额度体系改造:每日额度与VIP有效期 +overview: 重构额度体系:AI/Outfit/Closet 三类改为每日重置额度(每日总额度 = 免费配置 + 购买赠送,购买赠送随 VIP 有效期失效);Cos 保持永久累计,但购买赠送的 Cos 额度也随 VIP 过期失效。 +todos: + - id: dao-layer + content: 改造数据访问层:新增订单赠送额度汇总查询与 Cos 动态总额度消费方法 + status: pending + - id: redis-daily + content: 实现 Redis 每日额度原子限流:带限额 Lua 脚本及 consume/refund/value 封装 + status: pending + - id: service-core + content: 实现 Service 层额度核心:动态总额度计算、每日额度消费/回退、Cos 消费改造 + status: pending + dependencies: + - dao-layer + - redis-daily + - id: biz-switch + content: 切换 AI/Outfit/Closet 业务使用点为每日额度消费,Cos 适配动态总额度 + status: pending + dependencies: + - service-core + - id: order-benefit + content: 改造订单权益发放/退款/补偿链路:额度类不再落库发放,仅保留 VIP 发放 + status: pending + dependencies: + - service-core + - id: info-config-doc + content: 更新额度信息接口、配置语义注释与 README/接口文档 + status: pending + dependencies: + - biz-switch + - order-benefit +--- + +## 产品概述 + +将当前「AI 推荐、社区分享(OutfitShare)、衣柜分享(ClosetShare)、上传(Cos)」四类统一永久累计的额度体系,改造为混合额度模型: + +- **Cos(上传)**:永久额度,不每日重置;免费部分永久,购买赠送部分随 VIP 有效期生效 +- **AI / Outfit / Closet**:每日额度,每天 0 点重置 + +## 核心功能 + +1. **Cos 永久额度**:总额度 = 免费永久额度(配置 20)+ 购买赠送额度(仅 VIP 有效期内计入);已用量永久累计,删除衣物/处理失败时回退。 +2. **AI / Outfit / Closet 每日额度**:每日总额度 = 免费每日额度(配置 AI:3 / OutfitShare:3 / ClosetShare:0)+ 购买赠送额度(仅 VIP 有效期内计入);当日已用每天 0 点自动清零;ClosetShare:0 表示新用户无免费衣柜分享次数,只能靠购买赠送获得。 +3. **购买赠送额度随 VIP 过期失效**:用户 VIP 过期后,购买赠送的 Cos/AI/Outfit/Closet 额度全部失效;重新成为会员后恢复。 +4. **消耗顺序**:免费额度与购买赠送额度合并为单一额度池,无需区分消耗顺序(用户已确认「都可以」)。 +5. **额度信息接口**:`GET /api/v1/quota/info` 对 Cos 返回永久总额度/累计已用/剩余;对 AI/Outfit/Closet 返回每日总额度/当日已用/当日剩余。 + +## 技术栈 + +- 后端语言/框架:Go + Gin(复用现有) +- 数据层:GORM + MySQL(复用现有) +- 缓存/原子计数:Redis + go-redis(复用现有,新增 Lua 原子限流脚本) +- 迁移:golang-migrate(复用现有迁移机制) + +## 实现方案 + +### 核心策略:赠送额度动态计算、不落库 + +不再通过 `grantQuota` 把购买赠送额度累加进 `user_quotas.total`,而是每次查询/消费时**动态计算**有效总额度: + +- `每日总额度(AI/Outfit/Closet) = 免费配置值 + (VIP有效 ? SUM(有效订单对应赠送额度) : 0)` +- `永久总额度(Cos) = 免费配置值 + (VIP有效 ? SUM(有效订单 cos_quota) : 0)` + +「有效订单」= `order_status = paid` 且未退款(退款后状态变为 `refunded` 自动排除);VIP 有效性用 `user.vip_expire > now` 判断。免费底值直接读配置,不再依赖被历史数据污染过的 `user_quotas.total`。 + +### 分渠道消费机制 + +- **Cos(永久额度)**:继续走 MySQL 事务条件更新 `used`,但将 `total` 上限从表字段改为**动态计算后作为参数传入**,避免 total 被历史购买赠送污染。 +- **AI / Outfit / Closet(每日额度)**:用 Redis Lua 脚本原子完成「当日已用 + 1 且不超过每日总额度」的校验与计数,key 按 `user_id:YYYYMMDD` 区分并带 48h TTL 跨天自然过期;MySQL `user_daily_usages` 保留作为统计与 Redis 故障兜底。 + +### 为什么这样设计 + +- **不落库赠送额度**:赠送额度与 VIP 有效期强耦合,落库后需要额外的过期回收任务且易产生账实不符;动态计算天然跟随 VIP 状态与订单退款状态,消除过期/退款回收的复杂度和资损窗口。 +- **Cos 用 MySQL 事务、每日额度用 Redis 原子计数**:Cos 的 `used` 需要永久累计并写流水,适合事务;每日额度需要高频原子增减与每日清零,Redis INCR + TTL 天然适配,避免每日重置的定时任务。 +- **复用现有半成品**:`user_daily_usages` 表、`recordDailyUsage`、`QuotaDaily*` Redis 函数均已存在,只需补上「带限额的原子校验」与业务接入。 + +### 关键并发与一致性 + +- 每日额度消费:Redis Lua 脚本内完成「读当日计数 → 判断 < 每日总额度 → INCR → 首次设置 TTL」,保证不超发、并发安全。 +- 业务失败回退:AI 推荐失败、分享创建失败等沿用现有 `refundQuota` 思路,新增 `refundDailyQuota`(Redis DECR 并保证不小于 0)。 +- Cos 消费:在事务内先读取动态总额度,再条件更新 `used + n <= limit`,避免并发超扣;沿用现有「流水与数值同事务」的强一致模式。 +- 幂等:购买赠送额度不再创建额度发放流水,`BenefitGrantTask` 中 Cos/AI/Outfit/Closet 四类改为「记录支付完成但不实际发放」,只保留 VIP 实际发放。 + +### 性能考量 + +- 动态总额度需要 `SUM(orders.xxx_quota)`,已存在索引 `idx_orders_user_status(user_id, order_status)` 可支撑;商品 SKU 少、单用户订单量小,直接聚合开销可控。 +- 为避免高频消费时反复 SUM + 读用户,可在 Redis 增加「当日总额度缓存」(key 带 YYYYMMDD,TTL 到当天结束),订单支付/退款、VIP 变更时失效;实现时作为可选项,先以直接计算为主、缓存优化为增量。 + +## 架构设计 + +```mermaid +flowchart TD + subgraph 额度查询/消费入口 + Q[GET /quota/info] --> E[getEffectiveQuota] + C1[AI/Outfit/Closet 业务] --> D[consumeDailyQuota] + C2[Cos 上传] --> P[consumeCosQuota] + end + + E --> V{vip_expire > now?} + V -->|是| S[SUM 有效订单赠送额度] + V -->|否| F[免费配置值] + S --> T[每日/永久总额度] + + D --> L[Redis Lua 原子限流
当日计数 +1 且 <= 总额度] + L --> M[user_daily_usages 统计] + + P --> TX[MySQL 事务
used + 1 且 <= 动态总额度] + TX --> FLOW[写 quota_flow_logs] + + T --> Q +``` + +## 目录结构 + +``` +internal/ +├── service/ +│ ├── service.go # [MODIFY] 新增 getEffectiveQuota、consumeDailyQuota、refundDailyQuota;改造 Cos consumeQuota 支持动态总额度 +│ ├── constants.go # [MODIFY] 更新额度语义注释,补充每日额度/永久额度常量说明 +│ ├── quota.go # [MODIFY] ensureDefaultQuota 仅初始化 Cos 底值;InfoGetBySelf 返回每日额度信息 +│ ├── airecommend.go # [MODIFY] AI 消费改 consumeDailyQuota,失败回退 refundDailyQuota +│ ├── outfit.go # [MODIFY] OutfitShare 消费改 consumeDailyQuota +│ ├── closet.go # [MODIFY] ClosetShare 消费改 consumeDailyQuota +│ ├── clothes.go # [MODIFY] Cos 消费适配动态总额度(调用点基本不变,内部语义调整) +│ ├── order.go # [MODIFY] grantOrderBenefits/revokeOrderBenefits 不再发放/回退四类额度,仅保留 VIP;doGrantBenefit 对额度类直接置成功 +│ └── benefit_compensation.go # [MODIFY] 额度类补偿任务兼容(不发放直接成功) +├── mysqldao/ +│ ├── order.go # [MODIFY] 新增 OrderGrantedQuotaSumByUserID:SUM 有效订单四类赠送额度 +│ └── quota.go # [MODIFY] 新增 Cos 动态总额度消费方法(传入 limit 条件更新) +├── mysqlmodel/ +│ └── quota.go # [MODIFY] 模型注释更新(total 对 Cos 语义调整,AI/Outfit/Closet 不再使用) +├── redisdao/ +│ └── quota.go # [MODIFY] 新增带限额 Lua 脚本及 DailyQuotaConsume/Refund/Value 封装 +├── redismodel/ +│ └── quota.go # [MODIFY] 复用/补充每日额度 key 定义 +└── dto/ + └── quota.go # [MODIFY] QuotaItemResponse 字段语义注释(Cos 永久 vs 每日),按 quota_type 区分含义 +pkg/ +└── setting/ + └── section.go # [MODIFY] UserDefaultQuotaSettingS 注释更新(AI/Outfit/Closet 为每日免费额度) +configs/ +└── config.example.yaml # [MODIFY] UserDefaultQuota 段注释更新(Cos 永久、AI/Outfit/Closet 每日) +README.md # [MODIFY] 额度语义说明同步 +接口文档.md # [MODIFY] /quota/info 返回字段语义同步 +``` + +## 关键代码结构 + +``` +// internal/service/service.go +// getEffectiveQuota 返回某类额度的当日/永久有效总额度: +// Cos = 免费配置 + (VIP有效 ? 订单赠送cos : 0);AI/Outfit/Closet = 免费配置 + (VIP有效 ? 订单赠送对应值 : 0) +func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (int64, *errcode.Error) + +// consumeDailyQuota 原子扣减 AI/Outfit/Closet 当日额度:Redis Lua 校验限额 + INCR +func consumeDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64, reason string) *errcode.Error + +// refundDailyQuota 业务失败时回退当日已用计数(DECR 且不小于 0) +func refundDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64) +``` + +``` +-- internal/redisdao/quota.go 中的限流 Lua(伪代码示意) +-- KEYS[1]=daily count key, ARGV[1]=limit, ARGV[2]=ttl +local v = tonumber(redis.call('GET', KEYS[1]) or '0') +if v >= tonumber(ARGV[1]) then return -1 end +local n = redis.call('INCR', KEYS[1]) +if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end +return n +``` + +``` +// internal/mysqldao/order.go +// OrderGrantedQuotaSum 用户有效订单(order_status=paid)四类赠送额度汇总 +type OrderGrantedQuotaSum struct { + CosQuota int64 + AIRecommendQuota int64 + OutfitShareQuota int64 + ClosetShareQuota int64 +} +func (d *OrderDao) OrderGrantedQuotaSumByUserID(ctx context.Context, userID int64) (*OrderGrantedQuotaSum, error) +``` \ No newline at end of file diff --git a/.codebuddy/plans/额度体系重构_每日额度_凭证有效期_广告激励_defdda63.md b/.codebuddy/plans/额度体系重构_每日额度_凭证有效期_广告激励_defdda63.md new file mode 100644 index 0000000..103d1db --- /dev/null +++ b/.codebuddy/plans/额度体系重构_每日额度_凭证有效期_广告激励_defdda63.md @@ -0,0 +1,214 @@ +--- +name: 额度体系重构:每日额度+凭证有效期+广告激励 +overview: 重构额度体系:引入统一额度凭证表(quota_grants)承载会员包/加量包/广告激励三类有有效期赠送;AI/Outfit/Closet 改为每日重置额度,Cos 保持永久库存;新增广告激励发放接口与加量包有效期配置。 +todos: + - id: dao-migration + content: 新增 quota_grants 凭证表与 goods.valid_days 迁移,扩展模型与 DAO(凭证 CRUD/汇总/扣减) + status: completed + - id: redis-daily + content: 实现 Redis 每日额度原子限流:带限额 Lua 脚本及 consume/refund/value 封装 + status: completed + - id: service-core + content: 实现 Service 层额度核心:getEffectiveQuota、consumeDailyQuota、refundDailyQuota、Cos 凭证消费 + status: completed + dependencies: + - dao-migration + - redis-daily + - id: biz-switch + content: 切换 AI/Outfit/Closet/Cos 业务使用点为每日额度与凭证消费 + status: completed + dependencies: + - service-core + - id: order-benefit + content: 改造订单权益发放/退款/补偿链路为凭证模型,仅保留 VIP 实际发放 + status: completed + dependencies: + - dao-migration + - service-core + - id: ad-reward + content: 新增广告激励发放接口(服务/路由/DTO/幂等),创建当天有效凭证 + status: completed + dependencies: + - dao-migration + - service-core + - id: info-config-doc + content: 更新额度信息接口响应、配置语义注释与 README/接口文档 + status: completed + dependencies: + - biz-switch + - order-benefit + - ad-reward +--- + +## 产品概述 + +将当前 Cos/AI/Outfit/Closet 四类统一永久累计的额度体系,重构为「永久额度 + 每日额度 + 有有效期赠送凭证」的混合模型,并新增广告激励额度来源。 + +## 核心功能 + +**免费基础额度** + +- Cos 免费上传额度(配置 20):永久,不每天重置。 +- AI/Outfit/Closet 免费额度(配置 3/3/0):每日额度,每天 0 点重置;ClosetShare 为 0 表示新用户无免费衣柜分享次数。 + +**有有效期赠送额度(统一凭证模型)** + +- 会员包赠送:随 VIP 有效期,VIP 续期时自动延长(跟随 vip_expire 动态变化)。 +- 加量包赠送:独立有效期,每个商品可配置 valid_days 天数,到期失效。 +- 广告激励:可配置多类额度(AI/Cos 等),当天有效(当天 23:59:59 到期),后端新增发放接口并幂等防重复领取。 + +**额度计算与消耗** + +- Cos:有效总额度 = 免费配置 + 有效凭证剩余(amount-used);used 永久累计,凭证按先到期先用。 +- AI/Outfit/Closet:每日总额度 = 免费配置 + 有效凭证总额;当日已用每天 0 点清零。 +- 消耗顺序:免费与赠送合并为单一额度池,无需区分顺序。 + +**额度信息接口** + +- Cos 返回永久总额度/累计已用/剩余;AI/Outfit/Closet 返回每日总额度/当日已用/当日剩余。 + +## 技术栈 + +- 后端:Go + Gin(复用现有) +- 数据层:GORM + MySQL(复用现有) +- 缓存与原子计数:Redis + go-redis(复用现有,新增带限额 Lua 脚本) +- 迁移:golang-migrate(复用现有 pkg/migration 机制) + +## 实现方案 + +### 核心策略:统一额度凭证表 quota_grants + +新增凭证表承载所有「有有效期赠送」来源(会员包/加量包/广告激励),不再把赠送额度累加进 user_quotas.total,而是每次查询/消费时动态计算有效总额度: + +- Cos 有效总额 = 免费配置 + SUM(有效凭证 amount - used) +- AI/Outfit/Closet 每日总额 = 免费配置 + SUM(有效凭证 amount) + +「有效凭证」= expire_time > now,或 expire_time=0 且 user.vip_expire > now(会员包跟随 VIP)。 + +### 分渠道消费 + +- Cos(永久库存):MySQL 事务 + 凭证维度扣减(先到期先用,减少过期浪费),写 quota_flow_logs。 +- AI/Outfit/Closet(每日额度):Redis Lua 原子限流(读当日计数 → 判断小于每日总额 → INCR → 首次设置 48h TTL),MySQL user_daily_usages 保留为统计与兜底。 + +### 为什么这样设计 + +- 赠送额度不落库为 total,动态计算天然跟随 VIP 状态、加量包有效期与退款状态,消除过期回收任务与账实不符风险。 +- Cos 需永久累计 used 并写流水,走 MySQL 事务;每日额度需高频原子增减与每日清零,走 Redis INCR + TTL。 +- 复用现有 user_daily_usages 表与 QuotaDaily* Redis 半成品,只补齐「带限额原子校验」与业务接入。 + +### 并发与一致性 + +- 每日额度:Redis Lua 原子完成校验+计数,保证不超发;业务失败 refundDailyQuota(DECR 且不小于 0)。 +- Cos:事务内凭证行锁/条件更新,动态限额作为参数传入,避免并发超扣。 +- 广告激励:ref_id 唯一索引防重复领取。 +- 退款:将对应订单凭证失效/删除(未用部分回收),VIP 走现有 revokeVIP。 + +### 性能 + +- SUM 有效凭证已有 user_id+quota_type+expire_time 索引可支撑;商品与单用户凭证量小,直接聚合开销可控。 +- 每日总额度可加 Redis 当日缓存(key 带 YYYYMMDD,TTL 到当天结束),订单支付/退款、VIP 变更时失效;作为增量优化。 + +## 架构设计 + +```mermaid +flowchart TD + subgraph 额度查询/消费 + Q[GET /quota/info] --> E[getEffectiveQuota] + C1[AI/Outfit/Closet 业务] --> D[consumeDailyQuota] + C2[Cos 上传] --> P[consumeCosQuota] + A[广告激励接口] --> G[写 quota_grants 当天凭证] + end + + E --> V{vip_expire 或凭证 expire_time} + V --> S[SUM 有效 quota_grants] + S --> T[每日/永久总额度] + + D --> L[Redis Lua 原子限流] + L --> M[user_daily_usages 统计] + + P --> TX[MySQL 事务凭证扣减] + TX --> FLOW[写 quota_flow_logs] + + G --> QGRANT[quota_grants] +``` + +## 目录结构 + +``` +internal/ +├── mysqlmodel/ +│ ├── quota.go # [MODIFY] 新增 QuotaGrant 凭证模型;Quota 注释语义更新 +│ └── goods.go # [MODIFY] 新增 ValidDays 字段 +├── mysqldao/ +│ ├── quota.go # [MODIFY] 凭证 CRUD/汇总/扣减;Cos 动态限额消费 +│ └── goods.go # [MODIFY] ValidDays 相关查询/更新 +├── service/ +│ ├── service.go # [MODIFY] getEffectiveQuota、consumeDailyQuota、refundDailyQuota、Cos 消费改造 +│ ├── constants.go # [MODIFY] 额度来源/有效期常量补充 +│ ├── quota.go # [MODIFY] ensureDefaultQuota 调整;InfoGetBySelf 返回每日额度 +│ ├── airecommend.go # [MODIFY] AI 消费改 consumeDailyQuota +│ ├── outfit.go # [MODIFY] OutfitShare 消费改 consumeDailyQuota +│ ├── closet.go # [MODIFY] ClosetShare 消费改 consumeDailyQuota +│ ├── clothes.go # [MODIFY] Cos 消费适配凭证扣减 +│ ├── order.go # [MODIFY] 发放/退款改写凭证,不再 grantQuota 累加 total +│ ├── benefit_compensation.go # [MODIFY] 额度类补偿任务兼容 +│ └── adreward.go # [NEW] 广告激励发放服务(按类型、当天有效、幂等) +├── routers/ +│ ├── api/v1/adreward.go # [NEW] 广告激励发放 handler +│ └── router.go # [MODIFY] 注册广告激励路由 +├── redisdao/ +│ └── quota.go # [MODIFY] 带限额 Lua 脚本 + DailyQuotaConsume/Refund/Value +├── redismodel/ +│ └── quota.go # [MODIFY] 每日额度 key 定义复用/补充 +└── dto/ + ├── quota.go # [MODIFY] 响应结构区分永久/每日额度语义 + ├── goods.go # [MODIFY] 响应增加 valid_days + └── adreward.go # [NEW] 广告激励发放请求/响应 + +pkg/ +├── migration/ # [NEW] quota_grants 表、goods.valid_days、广告激励相关索引 +└── setting/section.go # [MODIFY] 配置语义注释/新增广告激励配置 + +global/setting.go # [MODIFY] 配置解析补充 +configs/config.example.yaml # [MODIFY] 配置语义注释 +README.md # [MODIFY] 额度语义说明 +接口文档.md # [MODIFY] /quota/info 与广告激励接口文档 +``` + +## 关键代码结构 + +``` +// internal/mysqlmodel/quota.go +// QuotaGrant 有有效期额度凭证(会员包/加量包/广告激励) +type QuotaGrant struct { + Model + UserID int64 `gorm:"column:user_id;index:idx_grant_user_type_expire,priority:1"` + QuotaType int8 `gorm:"column:quota_type;index:idx_grant_user_type_expire,priority:2"` + Amount int64 `gorm:"column:amount"` // 赠送总量 + Used int64 `gorm:"column:used"` // 已用(Cos 凭证扣减用) + Source int8 `gorm:"column:source"` // 1=会员包 2=加量包 3=广告激励 + ExpireTime int64 `gorm:"column:expire_time;index:idx_grant_user_type_expire,priority:3"` // 0=跟随VIP + RefID string `gorm:"column:ref_id;uniqueIndex:uk_grant_ref"` +} +``` + +``` +// internal/service/service.go +// getEffectiveQuota 返回某类额度有效总额度(Cos 永久库存 / 每日额度上限) +func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (int64, *errcode.Error) + +// consumeDailyQuota 原子扣减 AI/Outfit/Closet 当日额度 +func consumeDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64, reason string) *errcode.Error + +// refundDailyQuota 业务失败时回退当日计数 +func refundDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64) +``` + +``` +// internal/dto/adreward.go +type AdRewardGrantRequest struct { + RewardType string `json:"reward_type" binding:"required"` // ai/cos/... 可配置映射到 quota_type + Amount int64 `json:"amount" binding:"required,min=1"` + RefID string `json:"ref_id" binding:"required"` // 广告记录ID,幂等键 +} +``` \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c0c44a --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +storage/logs/* +storage/tmp/* +configs/config.yaml + +# ===== Python ===== +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +env/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# ===== 模型权重(模型缓存走 ~/.cache/huggingface,不入库;以下兜底防误提交)===== +*.pth +*.pt +*.ckpt +*.safetensors +thirdparty/matting_server/models/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f2cdbf3 --- /dev/null +++ b/README.md @@ -0,0 +1,1567 @@ +# zt-closet-backend + +智能衣橱微信小程序后端服务。基于 Go + Gin 构建,提供衣物管理、AI 衣物识别(分类/抠图)、穿搭与 AI 推荐、每日星座运势、社区互动(分享/收藏/点赞/通知)、额度体系、商品与微信支付订单等完整能力,并内置 OpenTelemetry 全链路可观测性与结构化日志。 + +## 核心功能 + +- **用户体系**:微信小程序登录(code2session,首次自动注册并初始化免费额度)、JWT 双 token(access/refresh)、个人资料维护。 +- **衣物管理**:图片上传(扩展名 + magic bytes 双重校验)→ 异步 AI 流水线(抠图 → 15 维分类)→ 衣物 CRUD 与处理状态查询。 +- **穿搭与 AI 推荐**:自建穿搭(含社区公开与审核)、基于星座运势 + 衣橱衣物的 AI 穿搭推荐(大模型生成,落库为"待用户保存")。 +- **运势**:每日 0 点定时生成十二星座运势,Redis 分布式锁保证多实例下仅执行一次,查询时支持按需兜底生成。 +- **社区互动**:衣柜分享(部分衣物 / 全量快照)、穿搭点赞与收藏(Redis 集合 + MySQL 双写)、站内通知(未读计数)。 +- **额度体系**:四类额度(上传 / AI 推荐 / 穿搭分享 / 衣柜分享)的扣减、回退、发放、回收;上传为**永久额度**,AI 推荐 / 穿搭分享 / 衣柜分享为**每日额度**(每天 0 点重置);购买赠送额度以**凭证**(`quota_grants`)发放——会员包随 VIP 有效期、加量包独立有效期(`goods.valid_days`)、广告激励当天有效;Cos 消费先扣即将过期凭证再扣免费额度,全部走事务 + 幂等流水。 +- **商品与订单**:商品(会员包 / 上传包 / AI 推荐包 / 分享包)、微信支付 V3 下单、支付与退款回调(验签 + 解密)、订单 15 分钟超时自动过期、权益发放失败自动补偿。 +- **可观测性**:OpenTelemetry Trace / Metric / Log 三信号 OTLP gRPC 上报;结构化 JSON 日志注入 `trace_id` / `span_id`。 + +## 技术栈 + +| 分类 | 技术 | +| --- | --- | +| 语言 | Go 1.27 | +| Web 框架 | Gin v1.12 | +| ORM / 数据库 | GORM + MySQL(含 soft_delete 软删除插件) | +| 缓存 / 锁 | go-redis v9(会话存储、缓存、分布式锁、计数) | +| 配置 | spf13/viper(仅读 YAML 文件) | +| 鉴权 | golang-jwt/v5(HS256 双 token) | +| 对象存储 | 腾讯云 COS SDK v5(含 CDN 签名 URL) | +| 微信生态 | 小程序 code2session 登录、微信支付 APIv3(wechatpay-go 官方 SDK)、小程序虚拟支付(XPay) | +| 可观测性 | OpenTelemetry v1.46(otelgin、otelslog、OTLP gRPC 导出) | +| 日志 | log/slog + lumberjack(JSON、按大小轮转压缩) | +| AI 推理服务 | Python FastAPI:Fashion-CLIP 衣物分类、BiRefNet 抠图 | + +## 目录结构 + +``` +zt-closet-backend/ +├── main.go # 入口:初始化配置/日志/DB/Redis/COS/支付,启动后台任务与 HTTP 服务 +├── cmd/ +│ └── migrate/ # 独立数据库迁移命令入口(up/down/steps/version/force) +├── configs/ +│ ├── config.example.yaml # 配置模板(含注释说明) +│ └── config.yaml # 实际配置(已被 .gitignore 忽略,需自行复制创建) +├── global/ # 全局单例:配置指针、DB/Redis/COS 引擎、OTel Tracer/Meter +├── internal/ +│ ├── dto/ # 请求/响应 DTO 与分页结构 +│ ├── middleware/ # RequestID / OTel / 请求日志(脱敏) / CORS / JWT 鉴权 +│ ├── mysqldao/ # MySQL 数据访问层(tracing span 命名 mysqldao.Xxx) +│ ├── mysqlmodel/ # GORM 数据表模型(18 张表) +│ ├── redisdao/ # Redis 数据访问层(tracing span 命名 redisdao.Xxx) +│ ├── redismodel/ # Redis 引擎初始化与 key 常量 +│ ├── routers/ # 路由注册(router.go)与各 handler(api/v1) +│ └── service/ # 业务逻辑层(16 个 service) +├── pkg/ +│ ├── app/ # HTTP 统一响应封装 {code, msg, data} +│ ├── classification/ # 衣物分类服务客户端 +│ ├── cos/ # 腾讯云 COS 客户端 +│ ├── errcode/ # 统一业务错误码(按业务域分段) +│ ├── lzcwyjwt/ # JWT 生成/解析 +│ ├── matting/ # 抠图服务客户端 +│ ├── migration/ # 数据库迁移:embed 迁移脚本 + Up/Down/Steps/Version/Force +│ │ # (目录下直接存放 000001~000016 版本化 SQL,up 建表/down 删表) +│ ├── setting/ # viper 配置读取与配置结构体 +│ ├── tokenhub/ # 腾讯 Tokenhub 大模型网关客户端(运势/AI 推荐生成 + 调度) +│ ├── tracing/ # OpenTelemetry 初始化与日志桥接 +│ ├── upload/ # 文件上传校验与存储 +│ └── wechat/ # 微信登录与微信支付 +├── thirdparty/ # Python AI 推理服务 +│ ├── classification_server/ # Fashion-CLIP 衣物分类(端口 8000) +│ └── matting_server/ # BiRefNet 抠图(端口 8001) +├── design/ # 数据库 ER 图(drawio) +├── sql/ # 占位目录(建表 SQL 已统一收归 pkg/migration) +├── scripts/ # 占位目录(暂无部署脚本) +└── storage/ # 本地存储(日志、上传临时文件,已被 gitignore) +``` + +> 说明:`sql/`、`scripts/`、`docs/` 目前为空占位目录;建表与索引由 `pkg/migration` 中的版本化迁移脚本维护,表模型见 `internal/mysqlmodel/`,ER 图见 `design/zt-closet-er.drawio`。 + +## 快速开始 + +### 1. 环境依赖 + +- Go 1.27+ +- MySQL 5.7+ / 8.0 +- Redis 5+ +- Python 3.9+(运行 AI 推理服务时需要;若仅做基础调试可先跳过) + +### 2. 配置准备 + +```bash +cp configs/config.example.yaml configs/config.yaml +``` + +编辑 `configs/config.yaml`,至少修改以下内容: + +- `Mysql`:数据库连接信息 +- `Redis`:Redis 连接信息 +- `JWT.SecretKey`:**必填**,须为强随机字符串,留空会导致启动失败 +- `Server.HttpPort`:服务监听端口(模板默认为 80) +- `Cos` / `WechatMini` / `WechatPay` / `WechatVirtualPay`:按需填写;未配置的第三方能力(COS、微信支付、虚拟支付)启动时仅告警、不阻断服务 + +> **重要**:`config.example.yaml` 中不包含 `Tokenhub` 段,但服务启动时会强制读取该段(缺失将导致启动 panic)。请手动在 `config.yaml` 中补充: + +```yaml +Tokenhub: + APIKey: your_tokenhub_api_key # 腾讯 Tokenhub 大模型网关 APIKey,用于生成运势与 AI 穿搭推荐 + Model: hy4 # 模型代号,留空时默认 hy4 +``` + +`APIKey` 留空不会阻断启动,但运势与 AI 推荐功能将不可用(后台任务仅记录错误日志)。 + +### 3. 准备数据库 + +先创建 `config.yaml` 中 `Mysql.DBName` 对应的**空数据库**(迁移脚本只建表,不建库): + +```sql +CREATE DATABASE IF NOT EXISTS `zt-closet` DEFAULT CHARSET utf8mb4; +``` + +再用独立命令执行迁移(服务启动时**不会**自动迁移): + +```bash +go run ./cmd/migrate up # 建表到最新版本(幂等,可重复执行) +go run ./cmd/migrate version # 查看当前版本与是否为脏库 +``` + +其他命令: + +```bash +go run ./cmd/migrate steps 1 # 向上 1 步(负数向下) +go run ./cmd/migrate down # 全量回滚(按依赖倒序删表,慎用) +go run ./cmd/migrate force 3 # 迁移中断导致 dirty 时,强制修正版本号 +``` + +迁移脚本位于 `pkg/migration`,共 17 个版本、22 张表,以 `go:embed` 打包进二进制;迁移记录保存在 `schema_migrations` 表。字段与索引以 `internal/mysqlmodel/` 的 GORM 模型为准,ER 图见 `design/zt-closet-er.drawio`。 + +### 4. 启动 AI 推理服务(可选,推荐) + +衣物上传后的抠图与分类依赖两个 Python 服务,地址与端口在配置的 `MattingModel` / `ClassificationModel` 段中约定(默认分别为 `8001` / `8000`)。 + +**抠图服务(BiRefNet):** + +```bash +cd thirdparty/matting_server +pip install -r requirements.txt +python main.py # 监听 0.0.0.0:8001 +``` + +- 模型使用 `ZhengPeng7/BiRefNet_lite`(small 变体),本地缓存优先、缺失时自动下载;可用环境变量 `BIREFNET_REPO_ID` 覆盖模型来源。 +- 默认从 `https://hf-mirror.com`(国内镜像)下载权重。 + +**分类服务(Fashion-CLIP):** + +```bash +cd thirdparty/classification_server +pip install -r requirements.txt +python main.py # 监听 0.0.0.0:8000 +``` + +- 模型使用 `patrickjohncyh/fashion-clip`,一次前向识别衣物 15 个维度(大类/子类别/风格/主副颜色/厚度/版型/材质/花纹/袖型/衣长/领型/正式程度/场合/视觉冲击力)。 +- 首次运行需下载模型权重,同样走 hf-mirror 镜像。 + +> 建议在带 GPU 的环境中部署两个推理服务;CPU 环境亦可运行(抠图服务自动退回 fp32)。 + +### 5. 编译运行 + +```bash +# 必须在项目根目录执行(配置路径固定为相对路径 configs/config.yaml) +go run . +``` + +服务启动后验证健康检查: + +```bash +curl http://localhost:/ping +# {"message":"pong"} +``` + +## 配置说明 + +配置文件为 `configs/config.yaml`(YAML),**仅从文件读取,不支持环境变量覆盖**。各配置段说明如下: + +| 配置段 | 说明 | 关键字段 | +| --- | --- | --- | +| `Server` | HTTP 服务 | `Domain`(CORS 允许域名,`*` 或不填允许所有来源)、`RunMode`(debug/release)、`HttpPort`、`ReadTimeout`/`WriteTimeout`(秒) | +| `Log` | 日志 | `LogSavePath`、`LogFileName`、`MaxSize`/`MaxBackups`/`MaxAge`(MB/份数/天数)、`LogLevel`(Debug/Info/Warn/Error) | +| `Mysql` | MySQL 连接池 | 用户名/密码/Host/Port/DBName 及连接池参数 | +| `Redis` | Redis 连接 | Host/Port/Password/DB 及连接池、重试、超时参数 | +| `Otel` | OpenTelemetry | `Enabled`、`ServiceName`、`ExporterEndpoint`(OTLP gRPC 端点)、`Insecure`、`SamplerRatio`(0~1) | +| `JWT` | 鉴权 | `SecretKey`(**必填**,空则启动失败)、`Issuer`、`AccessTokenExpire`(默认 7200s)、`RefreshTokenExpire`(默认 86400s) | +| `Cos` | 腾讯云 COS | SecretID/SecretKey、上传/下载域名、CDN 下载鉴权密钥 | +| `Upload` | 上传限制 | `TmpSavePath`、`MaxSizeMB`(默认 20)、`AllowedExtensions` 扩展名白名单 | +| `MattingModel` | 抠图服务地址 | `Host`/`Port`(默认 8001)、`MaxRetry`(默认 3) | +| `ClassificationModel` | 分类服务地址 | `Host`/`Port`(默认 8000)、`MaxRetry`(默认 3) | +| `WechatMini` | 微信小程序 | `AppID`/`AppSecret`(用于 code2session 登录) | +| `WechatPay` | 微信支付 V3 | `Enable`(是否启用,未开通置 false 不加载)/`AppID`/`MchID`/`APIv3Key`(**必须 32 字节**)/`SerialNo`/`PrivateKeyPath`/`NotifyURL`/`RefundNotifyURL` | +| `WechatVirtualPay` | 小程序虚拟支付(XPay) | `Enable`(是否启用)/`OfferID`/`EnvAppKey`(现网 AppKey)/`SandboxAppKey`(沙箱 AppKey)/`Token`(消息推送验签);与普通微信支付独立商户号、独立结算,虚拟商品(会员/额度包)须走此通道。**环境自动选择**:只填 `SandboxAppKey` 走沙箱测试(不真实扣款),只填 `EnvAppKey` 或两把都填走现网(现网优先) | + +> **虚拟支付道具同步由管理后台负责,服务端不会自动同步**:商品发布为虚拟支付道具(上传/发布、改价)由管理后台完成,并将微信侧返回的 `product_id` 与 `product_status` 回填到 `goods` 表后,虚拟商品才可下单。**运营改价后必须通过管理后台同步道具价格**,否则微信侧道具价格与本地不一致:微信扣款多于订单金额时发货金额校验会拒绝(用户付款拿不到权益),少于订单金额时产生差价资损。 +| `UserDefaultQuota` | 新用户免费额度与赠送会员 | `Cos`(**永久**额度,未配置回落 20)、`AI`/`OutfitShare`/`ClosetShare`(**每日**额度,未配置回落 3/10/5,每天 0 点重置;可显式配 **0** 表示不赠送)、`GiftVIPDay`(注册赠送体验会员天数,未配置回落 3 天,可配 **0** 关闭;负数回落默认) | +| `AdReward` | 广告激励额度 | `Enable`(是否开启,关闭时接口返回「广告激励未开启」)、`Cos`/`AI`/`OutfitShare`/`ClosetShare`(各类型**固定金额**,服务端定价,前端不传金额;可删或置 0 表示不发放该类型) | +| `Tokenhub` | 大模型网关 | `APIKey`、`Model`(默认 hy4);**模板中缺失,需手动补充(见快速开始)** | + +> 未配置(整段缺失或留空)的第三方能力:COS、微信支付与虚拟支付在启动时降级为告警并禁用对应功能;微信登录(`WechatMini`)未配置时服务可正常启动,但登录接口会返回"未配置"错误。`Tokenhub`、`MattingModel`、`ClassificationModel` 三段为启动强依赖(缺失会导致 panic),请务必配置。 + +## 业务流程 + +> 本章从**业务视角**介绍各业务域的闭环流转、状态机与关键业务规则(额度扣减/回退、审核、幂等、补偿等);接口层面的调用顺序与时序图见下文 [核心业务调用流程](#核心业务调用流程),接口入参/出参细节见 [接口调用详解](#接口调用详解)。 + +### 1. 业务流程总览 + +```mermaid +flowchart TD + A["微信登录
新用户自动注册"] --> B["初始化免费额度
默认 上传 20 / AI 3 / 搭配分享 10 / 衣柜分享 5
(可配置,允许为 0)"] + B --> C["上传衣物
校验 → 扣上传额度 → 异步 AI 抠图 + 分类"] + C --> D["衣橱管理
查看 / 修正属性 / 删除回退额度"] + D --> E["创建穿搭"] + D --> F["AI 穿搭推荐
扣 AI 额度 → 大模型生成 → 待用户保存"] + D --> G["衣柜分享
部分衣物 / 全量快照"] + E --> H["公开到社区
自动审核通过 → 扣搭配分享额度"] + H --> I["社区互动
点赞 / 收藏 / 站内通知"] + F --> E + B --> J{"额度不足?"} + J -- "是" --> K["选购商品 → 微信支付 → 权益到账"] + K --> B +``` + +全系统共 7 条业务主线: + +1. **注册与额度**:微信登录首次自动注册,初始化 Cos **永久**免费额度(内置默认 20),AI 推荐 / 搭配分享 / 衣柜分享为**每日**额度(内置默认 3/10/5,可由 `UserDefaultQuota` 覆盖,**允许显式配 0** 表示不赠送;未配置或负数回落默认),每日额度不落库、按当天计数每天 0 点重置;并赠送体验会员(`vip_type=4`,内置默认 3 天,可由 `UserDefaultQuota.GiftVIPDay` 覆盖,**允许配 0 关闭**),同时下发一条系统通知;会员已过期时接口返回的 `vip_type` 归一为 1。 +2. **衣物生命周期**:上传(校验 + 扣额)→ 异步 AI 处理(抠图 → 分类)→ 成功入库可被选用;失败自动回退额度;删除软删并回退额度。 +3. **穿搭与社区**:创建穿搭 → 公开(审核自动通过 + 扣分享额度)→ 其他用户点赞/收藏 → 触发通知。 +4. **衣柜分享**:部分衣物或全量快照分享 → 扣额度 → 属主/访客查看(访客访问即记录接收关系)→ 取消或到期懒过期;"分享给我的"列表联表查询接收关系。 +5. **交易与权益**:选购商品 → 下单(15 分钟有效)→ 微信支付回调发放权益 → 超时过期 / 退款回收,发放失败自动补偿。 +6. **AI 推荐**:扣 AI 额度 → 结合星座运势与衣橱生成 → 落库"待用户保存" → 保存转正式穿搭(公开仍需扣分享额度)。 +7. **通知**:点赞、支付成功、退款成功等业务动作触发站内通知,未读计数实时维护。 + +### 2. 衣物生命周期 + +衣物图片处理状态机(`image_process_status`): + +```mermaid +stateDiagram-v2 + [*] --> queued : 上传成功(同步扣上传额度 1 点) + queued --> matting : 异步任务开始 + matting --> classifying : 抠图完成 + classifying --> success : 分类完成 + classifying --> failed : 分类失败 + matting --> failed : 抠图失败 + queued --> failed : 保存/上传异常 + success --> deleted : 用户删除(回退额度) + failed --> deleted : 用户删除(不回退,失败时已回退) + + state "3 排队中" as queued + state "1 抠图中" as matting + state "2 分类中" as classifying + state "4 成功" as success + state "5 失败" as failed +``` + +处理流程: + +1. **上传校验**:扩展名白名单 `jpg/jpeg/png` + magic bytes(文件头)双重校验 + 大小 ≤ 20MB,任一项不通过即拒绝上传。 +2. **同步扣额**:先扣减上传额度 1 点,扣减成功后才创建衣物记录(初始状态 `3 排队中`)并立即返回 `clothes_uuid`;建记录失败自动回退额度。 +3. **异步 AI 流水线**:抠图 → 15 维分类在后台 goroutine 中执行;并发信号量上限 10、单任务超时 3 分钟;抠图/分类调用各带重试(默认 3 次、间隔 1s)。 +4. **失败处理**:任一环节异常即置 `5 失败` 并写入 `fail_reason`(如"图片抠图失败/图片分类失败");**失败自动回退上传额度**(仅当衣物仍未被删除,防止与删除回退双重回退);分类失败但抠图成功时保留抠好的图,供用户手动补全属性。 +5. **修正与删除**:`update` 支持 18 个属性字段(分类/风格/颜色/材质/袖型等,无枚举限制,支持自定义分类);删除为软删除,**处理状态非失败**(失败态已在失败路径回退过)且已扣额度时,在事务内(行锁 → 软删 → 条件扣减 → 写回退流水)回退额度。 + +### 3. 穿搭与社区 + +穿搭公开与审核状态机: + +```mermaid +stateDiagram-v2 + [*] --> created : 创建穿搭(1~10 件本人衣物) + created --> private : 默认私有 is_public=1 + private --> public : 公开 is_public=2(审核自动通过 + 扣搭配分享额度 1 点) + public --> private : 取消公开(不返还已扣额度) + public --> interacted : 其他用户点赞 / 收藏(触发点赞通知) + private --> [*] : 修改(明细先删后建)/ 删除(软删 + 明细物理删) + public --> [*] : 修改(明细先删后建)/ 删除(软删 + 明细物理删) + + state "创建(私有/待审核)" as created + state "1 私有" as private + state "2 公开(审核通过)" as public + state "社区互动" as interacted +``` + +处理流程: + +1. **创建**:1~10 件衣物(必须属于当前用户)、名称必填;默认 `is_public=1` 私有、`audit_status=1` 待审核、`status=1` 正常。 +2. **公开**:置 `is_public=2` 时审核**自动通过**(`audit_status 1→2`)并扣搭配分享额度 1 点;扣额失败则回退为私有。当前无人工驳回流程(`audit_status=3` 不通过为预留值)。 +3. **可见性**:`is_public=2` 且 `audit_status=2` 且 `status=1` 三者同时满足才对他人可见;他人仅可点赞/收藏公开可见的穿搭。 +4. **点赞 / 收藏**:先写 MySQL(联合唯一索引 upsert 幂等)再写 Redis 集合;取消时 MySQL 记录状态置 0(不删记录),可再次点赞/收藏;仅"从取消/无记录变为点赞"且非本人时才触发通知。 +5. **实现备注**:当前无独立"社区广场"浏览列表接口(仅"我的穿搭"列表 + 按 UUID 查看公开穿搭)。 + +### 4. 衣柜分享 + +分享状态机(`status`): + +```mermaid +stateDiagram-v2 + [*] --> normal : 创建分享(扣衣柜分享额度 1 点 + 自动审核通过) + normal --> cancelled : 用户取消(校验归属,不返还额度) + normal --> expired : 到期懒过期(访问详情时判定 now > ExpireTime) + normal --> banned : 封禁(预留状态,代码无写入点) + + state "1 正常" as normal + state "2 已取消" as cancelled + state "3 已过期" as expired + state "4 已封禁" as banned +``` + +处理流程: + +1. **创建**:`share_type=1` 部分衣物(指定衣物 UUID 列表,逐件校验归属 + 去重)或 `share_type=2` 全量快照(当前全部衣物 ID 的引用列表,非属性数据副本)。 +2. **扣额**:扣衣柜分享额度 1 点,明细落库失败自动回退(避免产生空分享);创建即自动审核通过。 +3. **过期机制**:`ExpireTime` 由创建者指定(0 表示永不);采用**懒过期**——访问详情时判定过期并更新状态,无定时扫描任务。 +4. **查看**:详情支持属主与访客视角(`is_owner`),访客计入访问统计;封禁状态对访问者隐藏(返回"不存在");已取消/已过期返回对应错误。 +5. **删除衣物影响**:不联动清理分享明细,被软删除的衣物在分享详情中自动过滤。 +6. **接收关系**:采用"访问即视为收到"——登录用户(非属主)成功访问他人分享详情时,幂等写入 `closet_share_receivers`(`share_uuid + receiver_user_id` 联合唯一);一个分享可被多个用户接收,重复访问不重复记录。 +7. **分享给我的列表**:`GET /closet-share/received` 联表分页返回当前用户收到的分享,额外返回分享者昵称/头像(`owner_name` / `owner_avatar`)。 +8. **实现备注**:service 层已支持访客(`userID=0`)视角,但当前路由注册在鉴权组内,未登录访客的免登录访问需结合 HTTP 层路由确认。 + +### 5. 交易与权益 + +订单状态机(`order_status`): + +```mermaid +stateDiagram-v2 + [*] --> pending : 创建订单(快照权益 + 微信 JSAPI 下单) + pending --> paid : 支付回调 SUCCESS(验签 + 金额校验 → 发放权益 → 通知) + pending --> expired : 超时 15 分钟(后台每分钟扫描) + pending --> cancelled : 用户主动取消(同步关闭微信订单) + expired --> paid : 过期后付款到账(补发货) + cancelled --> refunded : 已付款的取消订单自动发起退款 + paid --> refunded : 退款成功回调(回收权益 → 通知) + + state "1 待支付" as pending + state "2 已支付" as paid + state "3 已取消" as cancelled + state "4 已过期" as expired + state "5 已退款" as refunded +``` + +处理流程: + +1. **商品**:5 类商品(`1` 会员包 / `2` 上传包 / `3` AI 推荐包 / `4` 衣柜分享包 / `5` 搭配分享包),价格单位为**分**;仅 `1 上架` 状态可购买。 +2. **下单**:实际支付金额取 `discount_price`;下单时把商品权益快照(会员天数/各类额度)冗余进订单,防止商品后续变更导致错发;先落本地待支付订单再调微信 JSAPI,微信下单失败时订单保留并靠超时自动过期。同一用户可并存多个待支付订单(无防重限制)。 +3. **支付回调**:验签 + 解密 → 仅处理 `SUCCESS` → 幂等锁 30s → **金额校验**(回调金额与订单不一致则拒绝流转、人工介入)→ 待支付 → 已支付 → 发放权益 + 写支付信息 + 发订单通知。竞态兜底:已过期但已付款的订单补发货;已取消但已付款的订单生成**待审核退款单**,由管理员手动退款。 +4. **权益发放**:会员天数实际发放(叠加规则 `vip_expire = max(vip_expire, now) + 天数×86400`,续费顺延,会员类型按**续费后最终到期时长**推导——剩余 ≥365 天为年度会员、否则月度会员,避免年度会员续月包被降级);Cos / AI / 分享额度改为写入**额度凭证** `quota_grants`——会员包 `expire_time=0`(跟随 VIP)、加量包按 `goods.valid_days` 独立到期,幂等由 `(ref_id, quota_type)` 唯一索引保证;补偿任务 `order_no + benefit_type` 唯一保证不重发。 +5. **超时过期**:后台任务**每分钟**扫描 `待支付且 expire_time < now` 的订单批量置为已过期(条件更新天然幂等);过期后无权益可回收。 +6. **退款**:**所有退款均需管理员操作**——用户申请退款仅登记待审核退款单(`0 待处理`),运营审核后在微信后台手动退款,退款回调更新状态(`0 待处理 → 1 退款中 → 2 成功 / 3 失败`);「已取消但已付款」的订单同样生成待审核退款单而非自动退款。退款成功回调将订单置已退款、**回收权益**(会员天数不回退到过去;赠送额度通过失效凭证回收未用部分,已用不追回;已取消订单权益从未发放,跳过回收)并发通知。 +7. **发放失败补偿**:权益发放失败自动落 pending 任务,后台任务**每分钟**扫描重试(Redis 锁保证单实例),每轮最多 100 个,重试 3 次仍失败则标记失败 + 告警人工介入;重试前先查发放流水幂等。 +8. **额度变更统一收口**:Cos 永久额度扣减/回退封装在事务内「凭证扣减(先到期先用)+ 免费额度扣减 + 写流水」强一致;每日额度(AI/分享/衣柜分享)由 Redis 原子限流计数(当天 0 点自然过期),MySQL `user_daily_usages` 兜底统计。 + +### 6. AI 推荐 + +```mermaid +flowchart TD + A["发起推荐请求"] --> B{"Redis 锁 180s
(防并发重复生成)"} + B -- 抢锁失败 --> X["返回「请求过于频繁」"] + B -- 成功 --> C["扣 AI 额度 1 点
(生成前权威扣减)"] + C --> D["组装输入:
处理成功的全部衣物 + 星座 + 今日运势"] + D --> E["调用大模型生成
(tokenhub)"] + E --> F{"校验结果
(过滤模型幻觉衣物 ID)"} + F -- 有效 --> G["落库:source=2 AI、status=3 待用户保存"] + F -- 无效 / 任一步失败 --> Y["回退 AI 额度 + 写失败流水"] + G --> H["用户确认 Save:
status 3→1 转正式穿搭(不扣额)"] + H --> I["公开到社区仍需 Public
(扣搭配分享额度)"] +``` + +处理流程: + +1. **输入**:当前用户**处理成功(4)** 的全部衣物(分页遍历)+ 由生日推算的星座 + 今日运势(无生日/运势时降级为仅星座或空)。 +2. **扣额与回退**:**生成前**扣减 AI 额度 1 点;衣橱为空、模型调用失败、无有效单品、落库失败等任一情况均**统一回退额度**并写失败流水。 +3. **结果校验**:校正模型返回的衣物 ID(过滤幻觉),无有效单品则失败回退。 +4. **落库**:生成穿搭以 `source=2 AI`、`status=3 待用户保存` 入库(私有、待审核);明细落库失败则删除穿搭 + 回退额度。 +5. **保存转正**:仅 `status=3` 可保存(重复保存幂等),保存仅将状态 3→1,**不扣分享额度**;后续公开到社区仍走 Public 流程扣搭配分享额度。 +6. **实现备注**:不保存的"待保存"穿搭无自动清理任务;同用户并发请求由 Redis 锁(180s)拦截。 + +### 7. 通知触发规则 + +| 触发动作 | 通知类型 | 发送者 | 接收者 | 触发条件与防重 | +| --- | --- | --- | --- | --- | +| 点赞 | `1` 点赞 | 点赞者 | 穿搭作者 | 仅当"从取消/无记录变为点赞"且非本人点赞时触发 | +| 支付成功 | `2` 订单 | 系统(sender=0) | 买家 | 支付回调发放权益成功后触发;取消/过期不发 | +| 退款成功 | `2` 订单 | 系统(sender=0) | 买家 | 退款回调回收权益后触发 | +| 系统通知 | `3` 系统 | 预留 | — | 常量已定义,当前代码无发送点(需运营/后台触发) | + +- **发送实现**:写入 MySQL 通知表(`is_read=1` 未读)+ Redis 未读计数自增,best-effort(失败仅告警,不影响主业务)。 +- **未读/已读**:`is_read` 取值 `1 未读 / 2 已读`;标记已读为**单条接口**(条件更新"仅未读→已读"防并发重复扣减,随后 Redis 计数自减),无"全部已读"接口。 +- **未读计数**:Redis 优先,Redis 为 0/异常时回落 MySQL 统计并回填缓存。 + +> 以上 7 条业务主线的**接口调用顺序**(时序图)见下文 [核心业务调用流程](#核心业务调用流程)。 + +## API 概览 + +- 统一前缀:`/api/v1` +- 统一响应结构:成功 `{"code":0,"msg":"成功","data":{...}}`;失败 `{"code":<业务错误码>,"msg":<文案>,"details":[...]}`。HTTP 状态码大多为 200(按业务 code 处理),鉴权类错误返回 401/403,限流 429,系统错误 500。 +- 鉴权方式:`Authorization: Bearer `(登录/刷新接口获取)。 + +### 通用调用约定 + +#### 鉴权 + +- 需登录接口统一携带请求头 `Authorization: Bearer `;access_token 由登录接口返回,默认有效期 7200 秒(`JWT.AccessTokenExpire`)。 +- access_token 过期(业务码 `11002`,HTTP 401)时,前端调用 `POST /user/refresh-token` 换新,成功后携带新 access_token 重试原请求。 +- 鉴权中间件除校验 JWT 外,还会校验 Redis 会话状态与用户状态(必须为 `status=1` 正常,否则拒绝);Redis 故障时降级为仅校验 JWT。 + +#### 统一响应结构 + +成功(HTTP 200): + +```json +{ + "code": 0, + "msg": "成功", + "data": {} +} +``` + +- 无业务数据的接口(删除、取消、标记已读等)`data` 为空对象 `{}`。 + +失败: + +```json +{ + "code": 10000, + "msg": "请求参数错误", + "details": ["field: 错误说明"] +} +``` + +- `details` 仅在参数校验类错误时返回错误明细数组,非校验类错误无此字段。 +- **判断业务结果一律以 `code` 为准**,不能仅凭 HTTP 状态码。 + +#### HTTP 状态码与业务码映射 + +| HTTP 状态码 | 业务 code | 场景 | +| --- | --- | --- | +| 200 | 0 及其余全部业务码 | 成功与绝大多数业务错误(按 code 区分) | +| 401 | 11000 / 11001 / 11002 / 11004 | 未授权、Token 无效 / 已过期 / 缺失 | +| 403 | 12000 | 越权访问他人资源 | +| 429 | 10003 | 请求过于频繁(限流) | +| 500 | 19000–19003 / 19999 / 39001 / 39002 | 系统 / 数据库 / 缓存 / 下游 AI 服务错误 | + +#### 微信支付回调专用响应 + +`/order/pay/notify`、`/order/refund/notify` 是微信服务端回调,不走统一响应包装,按微信 V3 规范返回: + +- 受理成功:HTTP 200,`{"code":"SUCCESS","message":"成功"}` +- 受理失败:HTTP 500,`{"code":"FAIL","message":"<失败原因>"}` + +#### 分页约定 + +- 所有列表接口使用 GET query 参数:`page`(可选,≥1)、`page_size`(可选,1~50)。 +- 列表响应统一包含:`total`(总条数,int64)、`page`(当前页)、`page_size`(每页条数)、`list`(数据数组)。 + +#### 金额单位 + +- 所有金额字段(`price`、`discount_price`、`unit_price`、`pay_amount`、`refund_amount`)单位均为**分**(int64),前端展示需除以 100。 + +#### 幂等与动作切换 + +- 点赞 / 收藏 / 取消分享等写操作幂等,重复提交不会产生重复数据。 +- 点赞与收藏通过 `action` 字段切换:`1=执行(点赞/收藏)`、`2=取消`。 +- 删除衣物回退上传额度、权益发放等均由服务端事务 + 幂等流水保证一致,前端无需补偿处理。 + +### 健康检查 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/ping` | 存活探针,返回 `{"message":"pong"}` | + +### 公开路由(无需登录) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/user/wxlogin` | 微信登录(code 换 openid,首次自动注册) | +| POST | `/user/refresh-token` | 刷新 access token | +| GET | `/goods` | 商品列表 | +| GET | `/goods/:code` | 按编码查询商品 | +| POST | `/order/pay/notify` | 微信支付结果回调 | +| POST | `/order/refund/notify` | 微信退款结果回调 | + +### 鉴权路由(需登录) + +**用户 / 额度 / 运势** + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/user/info` | 查询个人信息 | +| PUT | `/user/profile` | 更新个人资料 | +| GET | `/quota/info` | 额度概览(四类额度余量) | +| GET | `/quota/flow` | 额度流水 | +| GET | `/horoscope/today` | 今日星座运势(按生日推算星座) | + +**衣物 / 穿搭** + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/clothes/upload` | 上传衣物图片(触发 AI 抠图/分类流水线) | +| GET | `/clothes` | 衣物列表 | +| GET | `/clothes/:uuid` | 衣物详情 | +| GET | `/clothes/:uuid/status` | 衣物 AI 处理状态 | +| PUT | `/clothes/:uuid` | 更新衣物信息 | +| DELETE | `/clothes/:uuid` | 删除衣物(返还上传额度) | +| POST | `/outfit` | 创建穿搭 | +| GET | `/outfit` | 穿搭列表 | +| GET | `/outfit/:uuid` | 穿搭详情 | +| PUT | `/outfit/:uuid` | 更新穿搭 | +| DELETE | `/outfit/:uuid` | 删除穿搭 | +| PUT | `/outfit/:uuid/public` | 公开穿搭(进入社区,需审核) | + +**分享 / 社区 / 通知** + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/closet-share` | 创建衣柜分享 | +| GET | `/closet-share` | 我的衣柜分享列表 | +| GET | `/closet-share/received` | 分享给我的列表 | +| GET | `/closet-share/:uuid` | 分享详情 | +| POST | `/closet-share/:uuid/cancel` | 取消分享 | +| POST | `/collect` | 收藏/取消收藏(由请求 Action 决定) | +| GET | `/collect` | 收藏列表 | +| GET | `/collect/:targetUUID` | 查询目标收藏状态与数量 | +| POST | `/like` | 点赞/取消点赞(由请求 Action 决定) | +| GET | `/like/:targetUUID` | 查询目标点赞情况 | +| GET | `/notification` | 通知列表 | +| GET | `/notification/unread` | 未读通知数 | +| GET | `/notification/:uuid` | 通知详情 | +| POST | `/notification/:uuid/read` | 标记已读 | + +**订单 / AI 推荐** + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/order` | 创建订单(微信 JSAPI 支付) | +| GET | `/order` | 订单列表 | +| POST | `/order/:orderNo/cancel` | 取消订单 | +| POST | `/order/refund` | 申请退款 | +| POST | `/ad-reward/grant` | 广告激励额度发放(当天有效,幂等;由 `AdReward` 配置开关控制,金额服务端定价) | +| POST | `/ai-recommend` | 生成 AI 穿搭推荐(扣减 AI 额度) | +| POST | `/ai-recommend/save` | 保存 AI 推荐结果为用户穿搭 | + +### 接口调用详解 + +> 本节为路由总表的字段级补充。请求参数位置标注:`json` = JSON 请求体、`query` = URL 查询参数、`path` = 路径参数、`form` = multipart 文件。路径中的 `:xxx` 为占位参数名,调用时替换为真实值。字段类型按 JSON 语义标注(string / int / int64 / bool / object / array)。 + +#### 公共数据结构 + +以下对象在多处接口中出现,统一在此定义,后文不再重复。 + +**用户对象(user_info)** + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| username | string | 昵称 | +| avatar | string | 头像地址 | +| sex | int | 1=男 2=女 | +| birth_day | int64 | 生日(秒级时间戳) | +| phone | string | 手机号 | +| is_new_user | bool | 是否本次新注册用户 | +| status | int | 1=正常 2=临时禁用 3=永久禁用 4=已注销 | +| ban_reason | string | 封禁原因 | +| ban_start | int64 | 封禁开始时间 | +| ban_expire | int64 | 封禁到期时间 | +| vip_type | int | 1=非会员 2=月度会员 3=年度会员 4=体验会员(注册赠送) | +| vip_expire | int64 | VIP 到期时间 | + +**衣物对象(ClothesItem)** + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| clothes_uuid | string | 衣物 UUID | +| category_main | string | 大类(上装/下装/连衣裙/外套/鞋/配饰) | +| category_sub | string | 小类(T恤/牛仔裤/运动鞋/发带/戒指等) | +| style | string | 风格 | +| color_main | string | 主颜色 | +| color_sub | string | 副颜色 | +| thickness | string | 厚度 | +| version | string | 版型(宽松/修身/阔腿/紧身/常规合身) | +| material | string | 材质(牛仔布/针织/皮质/纯棉/羊毛等) | +| pattern | string | 花纹 | +| sleeve_type | string | 袖型 | +| clothes_type | string | 衣长类型(短款/常规/中长/长款) | +| neckline_type | string | 领型 | +| formality | string | 正式程度(1-5) | +| occasions | string | 适用场合 | +| visual_impact | string | 视觉冲击力(1-5) | +| name | string | 名称 | +| remark | string | 备注 | +| processed_image | string | 处理后图片地址 | + +**穿搭对象(OutfitItem)** + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| outfit_uuid | string | 穿搭 UUID | +| outfit_name | string | 穿搭名称 | +| outfit_desc | string | 穿搭描述 | +| scene | string | 适合场景 | +| source | int | 1=用户创建 2=AI 推荐 | +| is_public | int | 1=不公开 2=公开 | +| audit_status | int | 1=待审核 2=审核通过 3=审核不通过 | +| items | array | 衣物列表,元素:`{"clothes_uuid": string}` | + +**商品对象(GoodsItem)** + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| id | int64 | 商品 ID | +| goods_code | string | 商品编码(下单时使用) | +| goods_name | string | 商品名称 | +| goods_type | int | 1=会员包 2=上传包 3=AI 推荐包 4=衣柜分享包 5=搭配分享包 | +| price | int64 | 原价(分) | +| discount_price | int64 | 售价(分) | +| member_days | int64 | 会员天数 | +| upload_quota | int64 | 上传额度 | +| closet_share_quota | int64 | 衣柜分享额度 | +| outfit_share_quota | int64 | 搭配分享额度 | +| description | string | 商品描述 | + +**订单对象(OrderItem)** + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| order_no | string | 订单号 | +| goods_name | string | 商品名称 | +| unit_price | int64 | 单价(分) | +| pay_amount | int64 | 实付金额(分) | +| order_status | int | 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 | +| member_days | int64 | 会员天数 | +| expire_time | int64 | 支付截止时间戳(秒) | +| pay_time | int64 | 支付时间戳(秒) | +| refund_time | int64 | 退款时间戳(秒) | +| refund_amount | int64 | 退款金额(分) | +| created_at | int64 | 创建时间戳(秒) | + +**通知对象(NotificationItem)** + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| notification_uuid | string | 消息 UUID | +| msg_type | int | 1=点赞通知 2=订单通知 3=系统通知 | +| title | string | 标题 | +| content | string | 内容 | +| is_read | int | 1=未读 2=已读 | +| created_at | int64 | 创建时间戳(秒) | + +**额度流水对象(QuotaFlowItem)** + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| quota_flow_uuid | string | 流水 UUID | +| quota_type | int | 1=上传 2=AI 3=社区分享 4=衣柜分享 | +| change_target | int | 1=配额调整 2=使用 | +| change_type | int | 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收 | +| change_num | int64 | 变更数量(正增负减) | +| new_value | int64 | 变更后数值 | +| reason | string | 变更原因 / 备注 | +| order_no | string | 关联订单号(购买类流水) | +| created_at | int64 | 创建时间戳(秒) | + +#### 1. 用户模块 + +**POST `/user/wxlogin` —— 微信登录(公开)** + +请求(json): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| code | string | 是 | 微信登录 code(`wx.login` 获取),首次登录自动注册并初始化免费额度 | + +响应 `data`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| access_token | string | 访问令牌,后续请求放入 `Authorization: Bearer` 头 | +| refresh_token | string | 刷新令牌,用于 access 过期后续期 | +| expire_time | int64 | access_token 过期时间(秒) | +| user_info | object | 用户对象(见公共结构) | + +相关错误码:21002 微信登录失败、21003 Code 无效。 + +**POST `/user/refresh-token` —— 刷新令牌(公开)** + +请求(json): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| refresh_token | string | 是 | 有效的 refresh_token | +| access_token | string | 是 | 旧 access_token,必须与 refresh_token 一起传入;服务端校验通过后立即吊销旧 access + 旧 refresh,缺少返回 10000 | + +响应 `data`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| access_token | string | 新访问令牌 | +| refresh_token | string | 新刷新令牌(旋转更新) | +| expire_time | int64 | 新 access_token 过期时间(秒) | + +**GET `/user/info` —— 查询当前用户信息(需鉴权)** + +无请求参数。响应 `data` = 用户对象(见公共结构)。 + +**PUT `/user/profile` —— 更新个人资料(需鉴权)** + +请求(json): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| username | string | 否 | 昵称,最长 32 | +| avatar | string | 否 | 头像地址,最长 500 | +| sex | int | 否 | 0=未知 1=男 2=女 | +| birth_day | int64 | 否 | 生日(秒级时间戳) | + +响应:`data` 为空对象 `{}`。 + +**POST `/user/avatar/upload` —— 上传头像(需鉴权)** + +multipart 上传,字段名 `file`;仅支持图片(jpeg/png/gif/webp),复用全站图片扩展名白名单与大小限制。上传成功后写入 COS(key 规则 `{用户UUID}/{用户ID}/avatar/{随机名}.{扩展名}`)并将头像 key 存入 `users.avatar`,**不消耗** COS 上传额度。响应 `data`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| avatar | string | 头像可访问 URL | + +#### 2. 额度模块 + +**GET `/quota/info` —— 额度概览(需鉴权)** + +无请求参数。响应 `data`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| quotas | array | 额度数组,元素见下表 | + +数组元素: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| quota_type | int | 1=上传 2=AI 3=社区分享 4=衣柜分享 | +| total | int64 | 总配额 | +| used | int64 | 已使用 | +| remain | int64 | 剩余配额 | + +**GET `/quota/flow` —— 额度流水(需鉴权)** + +请求(query): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| page / page_size | int | 否 | 分页(见通用约定) | +| quota_type | int | 否 | 1=上传 2=AI 3=社区分享 4=衣柜分享;不传 = 全部 | + +响应 `data.list` 元素 = 额度流水对象(见公共结构)。 + +#### 3. 运势模块 + +**GET `/horoscope/today` —— 今日星座运势(需鉴权)** + +无请求参数(星座按用户生日自动推算)。响应 `data`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| stat_date | int64 | 日期(格式 YYYYMMDD) | +| zodiac | string | 星座 | +| overall_score | int | 综合运势 1-5 分 | +| love_score | int | 爱情 1-5 分 | +| career_score | int | 事业 1-5 分 | +| wealth_score | int | 财运 1-5 分 | +| daily_text | string | 今日运势文案 | +| lucky_color | string | 今日幸运色 | +| lucky_number | string | 今日幸运数字 | + +#### 4. 衣物模块 + +图片处理状态枚举(`image_process_status`,全模块通用): + +| 值 | 含义 | +| --- | --- | +| 1 | 抠图中 | +| 2 | 分类中 | +| 3 | 排队中 | +| 4 | 成功 | +| 5 | 失败(配合 `fail_reason` 查看原因) | + +**POST `/clothes/upload` —— 上传衣物图片(需鉴权)** + +请求(form,multipart/form-data): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| file | file | 是 | 衣物图片文件(**字段名固定为 `file`**,无其他表单字段) | + +服务端校验:扩展名白名单(配置 `Upload.AllowedExtensions`)+ 大小上限(`Upload.MaxSizeMB`,默认 20MB)+ magic bytes 真图校验;上传即扣减一次上传额度。 + +响应 `data`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| clothes_uuid | string | 衣物 UUID,后续查询/更新/删除均用此值 | +| image_process_status | int | 处理状态(见枚举表),初始为 3=排队中 | + +相关错误码:30000 文件为空、30001 类型不支持、30002 超限、39000 保存失败、80000 额度不足。 + +**GET `/clothes` —— 衣物列表(需鉴权)** + +请求(query): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| page / page_size | int | 否 | 分页 | +| category_main | string | 否 | 按大类筛选 | +| category_sub | string | 否 | 按小类筛选 | + +响应 `data`:`total` / `page` / `page_size` + `list`(衣物对象数组,见公共结构)。 + +**GET `/clothes/:uuid` —— 衣物详情(需鉴权)** + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| uuid | path | string | 是 | 衣物 UUID | + +响应 `data` = 衣物对象(见公共结构)。 + +相关错误码:50000 衣物不存在、50005 无权操作该衣物。 + +**GET `/clothes/:uuid/status` —— 查询图片处理状态(需鉴权)** + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| uuid | path | string | 是 | 衣物 UUID | + +响应 `data`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| image_process_status | int | 处理状态(见枚举表) | +| fail_reason | string | 失败原因(状态为 5 时有效) | + +**PUT `/clothes/:uuid` —— 更新衣物信息(需鉴权)** + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| uuid | path | string | 是 | 衣物 UUID | + +请求(json,全部可选,传什么改什么): + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| category_main | string | 大类,最长 64 | +| category_sub | string | 小类,最长 64 | +| style | string | 风格,最长 64 | +| color | string | 主颜色,最长 64(**注意:请求字段名为 `color`,非 `color_main`**) | +| color_sub | string | 副颜色,最长 64 | +| thickness | string | 厚度,最长 64 | +| version | string | 版型,最长 64 | +| material | string | 材质,最长 64 | +| pattern | string | 花纹,最长 64 | +| sleeve_type | string | 袖型,最长 64 | +| clothes_type | string | 衣长类型,最长 64 | +| neckline_type | string | 领型,最长 64 | +| formality | string | 正式程度(1-5),最长 16 | +| occasions | string | 适用场合,最长 255 | +| visual_impact | string | 视觉冲击力(1-5),最长 16 | +| name | string | 名称,最长 64 | +| remark | string | 备注,最长 500 | + +响应:`data` 为空对象 `{}`。 + +**DELETE `/clothes/:uuid` —— 删除衣物(需鉴权)** + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| uuid | path | string | 是 | 衣物 UUID | + +响应:`data` 为空对象 `{}`。删除成功自动回退上传额度。 + +相关错误码:50000 衣物不存在、50001 已被搭配使用无法删除。 + +#### 5. 穿搭模块 + +穿搭枚举汇总:来源 `source` 1=用户创建 2=AI 推荐;公开 `is_public` 1=不公开 2=公开;审核 `audit_status` 1=待审核 2=通过 3=不通过。 + +**POST `/outfit` —— 创建穿搭(需鉴权)** + +请求(json): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| outfit_name | string | 是 | 穿搭名称,最长 32 | +| outfit_desc | string | 否 | 穿搭描述,最长 500 | +| scene | string | 否 | 适合场景,最长 100 | +| source | int | 否 | 1=用户创建 2=AI 推荐 | +| items | array | 是 | 衣物列表,1~10 项,元素:`{"clothes_uuid": string}` | + +响应 `data` = 穿搭对象(见公共结构)。 + +**GET `/outfit` —— 我的穿搭列表(需鉴权)** + +请求(query): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| page / page_size | int | 否 | 分页 | +| is_public | int | 否 | 1=不公开 2=公开 | +| source | int | 否 | 1=用户创建 2=AI 推荐 | + +响应 `data.list` 元素 = 穿搭对象(见公共结构)。 + +**GET `/outfit/:uuid` —— 穿搭详情(需鉴权)** + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| uuid | path | string | 是 | 穿搭 UUID | + +响应 `data` = 穿搭对象(见公共结构)。 + +相关错误码:40000 穿搭不存在、40004 无权操作该穿搭。 + +**PUT `/outfit/:uuid` —— 更新穿搭(需鉴权)** + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| uuid | path | string | 是 | 穿搭 UUID | + +请求(json): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| outfit_name | string | 是 | 穿搭名称,最长 32 | +| outfit_desc | string | 否 | 穿搭描述,最长 500 | +| scene | string | 否 | 适合场景,最长 100 | +| items | array | 是 | 衣物列表,1~10 项,元素:`{"clothes_uuid": string}` | + +响应:`data` 为空对象 `{}`。 + +**DELETE `/outfit/:uuid` —— 删除穿搭(需鉴权)** + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| uuid | path | string | 是 | 穿搭 UUID | + +响应:`data` 为空对象 `{}`。 + +**PUT `/outfit/:uuid/public` —— 设置公开状态(需鉴权)** + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| uuid | path | string | 是 | 穿搭 UUID | + +请求(json): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| is_public | int | 是 | 1=不公开 2=公开(公开进入社区,需审核) | + +响应:`data` 为空对象 `{}`。 + +#### 6. 衣柜分享模块 + +分享状态枚举:`status` 1=正常 2=已取消 3=已过期 4=已封禁。 + +**POST `/closet-share` —— 创建衣柜分享(需鉴权)** + +请求(json): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| share_type | int | 是 | 1=部分衣物分享 2=全量快照分享 | +| clothes_uuids | array | 部分分享必填 | 衣物 UUID 数组(share_type=1 时必填) | +| expire_time | int64 | 否 | 过期时间戳(秒) | + +响应 `data`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| share_uuid | string | 分享 UUID,供他人访问分享详情 | + +**GET `/closet-share` —— 我的分享列表(需鉴权)** + +请求(query): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| page / page_size | int | 否 | 分页 | +| status | int | 否 | 1=正常 2=已取消 3=已过期 4=已封禁;不传 = 全部 | + +响应 `data.list` 元素: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| share_uuid | string | 分享 UUID | +| share_type | int | 1=部分衣物分享 2=全量快照分享 | +| clothes_count | int | 衣物数量 | +| expire_time | int64 | 过期时间戳(秒) | +| status | int | 1=正常 2=已取消 3=已过期 4=已封禁 | + +**GET `/closet-share/:uuid` —— 分享详情(需鉴权,访客视角)** + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| uuid | path | string | 是 | 分享 UUID | + +响应 `data`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| share_uuid | string | 分享 UUID | +| owner_name | string | 分享者昵称 | +| owner_avatar | string | 分享者头像 | +| clothes | array | 衣物对象数组(见公共结构) | +| expire_time | int64 | 过期时间戳(秒) | +| status | int | 1=正常 2=已取消 3=已过期 4=已封禁 | +| is_owner | bool | 是否本人分享 | + +相关错误码:90100 分享不存在、90101 分享已过期、90102 分享已取消。 + +**POST `/closet-share/:uuid/cancel` —— 取消分享(需鉴权)** + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| uuid | path | string | 是 | 分享 UUID | + +无请求体,响应为操作结果(空对象)。 + +#### 7. 点赞 / 收藏模块 + +动作枚举:`action` 1=执行(点赞/收藏)2=取消,重复调用幂等。 + +**POST `/collect` —— 收藏 / 取消收藏(需鉴权)** + +请求(json): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| target_uuid | string | 是 | 目标(社区穿搭)UUID | +| action | int | 是 | 1=收藏 2=取消收藏 | + +响应:`data` 为空对象 `{}`。 + +相关错误码:90200 目标内容不存在、90202 收藏操作失败。 + +**GET `/collect` —— 我的收藏列表(需鉴权)** + +请求(query):仅 `page` / `page_size` 分页参数。 + +响应 `data.list` 元素 = 穿搭对象(见公共结构)。 + +**POST `/like` —— 点赞 / 取消点赞(需鉴权)** + +请求(json): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| target_uuid | string | 是 | 目标穿搭 UUID | +| action | int | 是 | 1=点赞 2=取消点赞 | + +响应:`data` 为空对象 `{}`。 + +相关错误码:90201 点赞操作失败。 + +**GET `/like/:targetUUID` —— 查询点赞状态与数量(需鉴权)** + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| targetUUID | path | string | 是 | 目标穿搭 UUID(**参数名为 `targetUUID`,非 `uuid`**) | + +响应 `data`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| is_liked | bool | 当前用户是否已点赞 | +| like_count | int64 | 点赞总数 | + +#### 8. 通知模块 + +**GET `/notification` —— 通知列表(需鉴权)** + +请求(query): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| page / page_size | int | 否 | 分页 | +| is_read | int | 否 | 1=未读 2=已读;不传 = 全部(**注意:不是布尔值**) | + +响应 `data.list` 元素 = 通知对象(见公共结构)。 + +**GET `/notification/unread` —— 未读通知数(需鉴权)** + +无请求参数。响应 `data`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| unread_count | int64 | 未读通知数 | + +**GET `/notification/:uuid` —— 通知详情(需鉴权)** + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| uuid | path | string | 是 | 通知 UUID | + +响应 `data` = 通知对象(见公共结构)。 + +相关错误码:90000 通知不存在。 + +**POST `/notification/:uuid/read` —— 标记已读(需鉴权)** + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| uuid | path | string | 是 | 通知 UUID | + +无请求体,响应为操作结果(空对象)。 + +#### 9. 商品模块(公开) + +商品类型枚举:`goods_type` 1=会员包 2=上传包 3=AI 推荐包 4=衣柜分享包 5=搭配分享包。 + +**GET `/goods` —— 商品列表(公开)** + +请求(query): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| page / page_size | int | 否 | 分页 | +| goods_type | int | 否 | 商品类型(见枚举),不传 = 全部 | + +响应 `data.list` 元素 = 商品对象(见公共结构,仅返回上架商品)。 + +**GET `/goods/:code` —— 按编码查询商品(公开)** + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| code | path | string | 是 | 商品编码(goods_code) | + +响应 `data` = 商品对象(见公共结构)。 + +相关错误码:60000 商品不存在、60001 商品已下架。 + +#### 10. 订单模块 + +订单状态枚举:`order_status` 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款;退款状态 `status` 0=待处理 1=退款中 2=退款成功 3=退款失败。 + +**POST `/order` —— 创建订单并发起微信支付(需鉴权)** + +请求(json): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| goods_code | string | 是 | 商品编码(**唯一请求字段**,金额由服务端按商品售价计算,客户端不传金额) | + +响应 `data`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| order_no | string | 订单号 | +| pay_amount | int64 | 应付金额(分) | +| expire_time | int64 | 支付截止时间戳(秒),前端倒计时用(15 分钟) | +| pay_params | object | 小程序拉起支付参数(微信原生命名,注意大小写):`timeStamp`、`nonceStr`、`package`、`signType`、`paySign` | + +相关错误码:60000 商品不存在、60001 已下架、79000 创建订单失败。 + +**GET `/order` —— 我的订单列表(需鉴权)** + +请求(query): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| page / page_size | int | 否 | 分页 | +| status | int | 否 | 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款;不传 = 全部 | + +响应 `data.list` 元素 = 订单对象(见公共结构)。 + +**POST `/order/:orderNo/cancel` —— 取消订单(需鉴权)** + +| 参数 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| orderNo | path | string | 是 | 订单号(**参数名为 `orderNo`,非 `uuid`**) | + +无请求体,响应为操作结果(空对象)。仅待支付订单可取消。 + +**POST `/order/refund` —— 申请退款(需鉴权)** + +请求(json): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| order_no | string | 是 | 订单号 | +| reason | string | 是 | 退款原因,最长 200 | + +响应 `data`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| refund_no | string | 退款单号 | +| refund_amount | int64 | 退款金额(分) | +| status | int | 0=待处理 1=退款中 2=退款成功 3=退款失败 | +| apply_time | int64 | 申请时间戳(秒) | + +相关错误码:71000 退款记录不存在、71001 当前状态不允许退款、71002 退款金额不合法、71003 退款申请失败。 + +**POST `/order/pay/notify` —— 微信支付回调(公开,微信服务端调用)** + +请求体为微信支付回调原始报文(服务端验签 + 解密)。响应遵循微信规范(见通用调用约定),前端**不要**手动调用。 + +**POST `/order/refund/notify` —— 微信退款回调(公开,微信服务端调用)** + +请求体为微信退款回调原始报文。响应遵循微信规范(见通用调用约定),前端**不要**手动调用。 + +#### 11. AI 推荐模块 + +**POST `/ai-recommend` —— 发起 AI 穿搭推荐(需鉴权)** + +请求(json): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| scene_type | int | 是 | 1=每日推荐 2=个性化场景 | +| scene | string | 否 | 用户自定义场景描述(scene_type=2 时使用) | + +响应 `data`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| outfit_uuid | string | 生成结果 UUID | +| outfit_name | string | 穿搭名称 | +| outfit_desc | string | 穿搭描述 | +| scene | string | 场景 | +| source | int | 1=用户创建 2=AI 推荐 | +| status | int | 1=正常 2=封禁 3=待用户保存 | +| outfit_item | array | 衣物列表,元素:`{"clothes_uuid": string}` | + +相关错误码:90400 AI 推荐失败、90401 AI 推荐次数已达上限、80000 额度不足。 + +**POST `/ai-recommend/save` —— 保存 AI 推荐为我的穿搭(需鉴权)** + +请求(json): + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| outfit_uuid | string | 是 | AI 推荐结果 UUID(由 `/ai-recommend` 返回) | + +响应:`data` 为空对象 `{}`。 + +### 错误码 + +业务错误码按业务域分段(`pkg/errcode`),常见分段如下: + +| 段位 | 业务域 | +| --- | --- | +| 0 | 成功 | +| 10000–10999 | 通用/参数(10000 参数错误、10001 不存在、10003 限流) | +| 11000–11999 | 鉴权(11000 未授权、11001 Token 鉴权失败、11002 Token 已过期、11004 Token 缺失) | +| 12000–12999 | 权限(12000 无权限) | +| 19000–19999 | 系统错误(19000 服务器错误、19001 数据库、19002 缓存) | +| 20000–29999 | 用户(21000 用户名或密码错误、21002 微信登录失败、22000 用户不存在) | +| 30000–39999 | 上传(30000 空文件、30001 扩展名不允许、30002 超限、39001 抠图失败、39002 分类失败) | +| 40000–49999 | 穿搭 | +| 50000–59999 | 衣物 | +| 60000–69999 | 商品 | +| 70000–79999 | 订单/退款(70000 订单不存在、70004 订单已过期) | +| 80000–89999 | 额度/VIP(80000 额度不足、80004 广告激励未开启) | +| 90000–99999 | 通知/分享/社区/星座/AI 推荐(90000 通知不存在、90400 AI 推荐失败、90401 AI 推荐次数已达上限) | + +### 核心业务调用流程 + +> 以下为前后端联调时的关键调用时序(业务视角的状态机与业务规则见上文 [业务流程](#业务流程))。参与方:小程序前端、后端服务、微信服务端、AI 推理服务与大模型网关。 + +#### 流程 1:登录与 Token 刷新 + +```mermaid +sequenceDiagram + participant F as 小程序前端 + participant B as 后端服务 + participant W as 微信服务端 + + F->>B: POST /user/wxlogin {code} + B->>W: code2session(code) + W-->>B: openid / session_key + B->>B: 首次登录:注册用户 + 初始化免费额度 + B-->>F: {access_token, refresh_token, expire_time, user_info} + + Note over F,B: access_token 过期(业务码 11002 / HTTP 401) + + F->>B: POST /user/refresh-token {refresh_token, access_token?} + B-->>F: 新 {access_token, refresh_token, expire_time} + F->>B: 携带新 token 重试原请求 +``` + +1. 前端调用 `wx.login` 获取 code,POST 到 `/user/wxlogin`;后端经微信 code2session 换取 openid,首次登录自动注册并初始化免费额度。 +2. 前端保存双 token;此后所有需鉴权接口携带 `Authorization: Bearer `。 +3. 收到业务码 11002(HTTP 401)时调用 `/user/refresh-token` 换新(可带上旧 access_token 使其立即吊销),成功后用新 token 重试。 + +#### 流程 2:衣物上传与 AI 处理轮询 + +```mermaid +sequenceDiagram + participant F as 小程序前端 + participant B as 后端服务 + participant M as 抠图服务(8001) + participant C as 分类服务(8000) + + F->>B: POST /clothes/upload (multipart: file) + B->>B: 校验扩展名/大小/magic bytes + 扣上传额度 + B-->>F: {clothes_uuid, image_process_status: 3 排队中} + + Note over B,M: 异步:排队(3) → 抠图(1) → 分类(2) → 成功(4)/失败(5) + + B->>M: 抠图请求(失败重试 ≤ MaxRetry) + B->>C: 15 维分类请求(失败重试 ≤ MaxRetry) + + loop 前端轮询(建议 1~2 秒间隔) + F->>B: GET /clothes/:uuid/status + B-->>F: {image_process_status, fail_reason} + end + + alt 状态为 4 成功 + F->>B: GET /clothes/:uuid 读取识别结果 + else 状态为 5 失败 + Note over F: 查看 fail_reason,可重新上传 + end +``` + +1. 上传接口**同步返回**(不等待 AI 完成):`clothes_uuid` + 初始状态 `3`(排队中)。 +2. 后端异步流水线:3 排队 → 1 抠图中(BiRefNet,端口 8001)→ 2 分类中(Fashion-CLIP,端口 8000)→ 4 成功 / 5 失败。 +3. 前端轮询 `GET /clothes/:uuid/status`(建议间隔 1~2 秒),直到状态为 4 或 5。 +4. 成功后调用 `GET /clothes/:uuid` 读取识别结果,可用 `PUT /clothes/:uuid` 修正识别值(注意该接口主颜色字段名为 `color`)。 +5. 失败时根据 `fail_reason` 判断,可重新上传。 + +#### 流程 3:AI 穿搭推荐与保存 + +```mermaid +sequenceDiagram + participant F as 小程序前端 + participant B as 后端服务 + participant L as 大模型网关(Tokenhub) + + F->>B: POST /ai-recommend {scene_type, scene?} + B->>B: 校验并扣减 AI 额度 + B->>L: 结合星座运势 + 衣橱衣物生成推荐 + L-->>B: 推荐结果 + B->>B: 落库(status=3 待用户保存) + B-->>F: {outfit_uuid, outfit_name, outfit_item...} + + F->>F: 用户预览、确认 + F->>B: POST /ai-recommend/save {outfit_uuid} + B-->>F: 保存成功,转入我的穿搭 +``` + +1. 请求必带 `scene_type`(1=每日推荐 2=个性化场景),每次调用扣减一次 AI 额度,不足返回 80000 / 90401。 +2. 推荐结果先落库为"待用户保存"状态(status=3),响应中返回完整推荐内容供预览。 +3. 用户确认后调用 `/ai-recommend/save`,结果转为用户正式穿搭;未保存的结果不进入穿搭列表。 + +#### 流程 4:创建订单到支付完成 + +```mermaid +sequenceDiagram + participant F as 小程序前端 + participant B as 后端服务 + participant W as 微信支付 + + F->>B: POST /order {goods_code} + B->>B: 按商品售价计算金额,创建订单(15 分钟有效) + B->>W: 微信 JSAPI 下单 + B-->>F: {order_no, pay_amount, expire_time, pay_params} + + F->>W: wx.requestPayment(pay_params) + W-->>B: POST /order/pay/notify(验签 + 解密) + B->>B: 订单置为已支付,发放权益(会员/额度) + + F->>B: GET /order 确认支付结果 + B-->>F: {order_status: 2 已支付} + + opt 超时未支付 + B->>B: 后台任务每分钟扫描,超 15 分钟置为已过期 + end +``` + +1. 创建订单只传 `goods_code`,金额由服务端按商品售价计算(单位:分),前端不传金额。 +2. 前端用响应中的 `pay_params` 调 `wx.requestPayment`;`expire_time` 用于支付倒计时(15 分钟)。 +3. 支付结果以微信回调为准(公开路由,服务端验签 + 解密);前端支付完成后应通过 `GET /order` 确认 `order_status=2`,不要自行判定支付成功。 +4. 超时未支付订单由后台任务每分钟扫描并自动置为已过期(4)。 +5. 退款:`POST /order/refund` 申请 → 微信异步处理 → 退款回调更新退款状态;可通过订单列表查看 `refund_time` / `refund_amount`。 + +#### 流程 5:点赞 / 收藏切换 + +```mermaid +sequenceDiagram + participant F as 小程序前端 + participant B as 后端服务 + + F->>B: GET /like/:targetUUID 获取初始状态 + B-->>F: {is_liked, like_count} + + F->>B: POST /like {target_uuid, action: 1 点赞} + B-->>F: 成功(幂等) + + F->>B: POST /like {target_uuid, action: 2 取消} + B-->>F: 成功(幂等) + + Note over F,B: 收藏同理:POST /collect {target_uuid, action} +``` + +1. 进入详情页先调 `GET /like/:targetUUID` 获取 `is_liked` / `like_count` 渲染状态。 +2. 点击切换调用 `POST /like`(`action` 1=点赞 2=取消),服务端幂等,重复提交安全。 +3. 收藏同理:`GET /collect` 查看收藏列表,`POST /collect` 切换收藏状态。 + +### 接口调用示例 + +> 以下示例中的 token、UUID、时间戳等业务数据均为示意值;实际以接口返回为准。 + +**微信登录:** + +```bash +curl -X POST http://localhost:80/api/v1/user/wxlogin \ + -H "Content-Type: application/json" \ + -d '{"code": "0b3abcxxxxxxxx"}' +``` + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "eyJhbGciOiJIUzI1NiIs...", + "expire_time": 1756742400, + "user_info": { + "username": "微信用户", + "avatar": "", + "sex": 0, + "birth_day": 0, + "phone": "", + "is_new_user": true, + "status": 1, + "ban_reason": "", + "ban_start": 0, + "ban_expire": 0, + "vip_type": 4, + "vip_expire": 1744761600 + } + } +} +``` + +**上传衣物图片(multipart):** + +```bash +curl -X POST http://localhost:80/api/v1/clothes/upload \ + -H "Authorization: Bearer " \ + -F "file=@/path/to/t-shirt.png" +``` + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "clothes_uuid": "c8f2e1a0-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "image_process_status": 3 + } +} +``` + +**轮询处理状态:** + +```bash +curl http://localhost:80/api/v1/clothes/c8f2e1a0-xxxx-xxxx-xxxx-xxxxxxxxxxxx/status \ + -H "Authorization: Bearer " +``` + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "image_process_status": 4, + "fail_reason": "" + } +} +``` + +**创建订单(微信支付):** + +```bash +curl -X POST http://localhost:80/api/v1/order \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"goods_code": "<商品编码,如 AI_3>"}' +``` + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "order_no": "20260907000123", + "pay_amount": 990, + "expire_time": 1756743300, + "pay_params": { + "timeStamp": "1756742400", + "nonceStr": "1a2b3c4d5e6f", + "package": "prepay_id=wx09153000123456", + "signType": "RSA", + "paySign": "xxxxxxxxxxxxxxxx" + } + } +} +``` + +**AI 穿搭推荐:** + +```bash +curl -X POST http://localhost:80/api/v1/ai-recommend \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"scene_type": 2, "scene": "参加朋友的婚礼"}' +``` + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "outfit_uuid": "of-xxxxxxxxxxxx", + "outfit_name": "婚礼宾客穿搭", + "outfit_desc": "简约大方,正式又不失亲和力", + "scene": "参加朋友的婚礼", + "source": 2, + "status": 3, + "outfit_item": [ + { "clothes_uuid": "c8f2e1a0-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } + ] + } +} +``` + +## 可观测性与后台任务 + +### 可观测性 + +- **Trace/Metric/Log** 三信号通过 OTLP gRPC 上报(`Otel.Enabled=true` 时);初始化失败自动降级为 no-op,不阻断启动。 +- 请求链路:每请求生成 `X-Request-ID`,Gin 中间件自动开启 server span,日志中注入 `trace_id`/`span_id`。 +- 结构化日志(JSON)同时输出到文件(lumberjack 滚动压缩)与 stdout;请求日志对 query 中的 `token`/`code`/`secret`/`password` 等参数自动脱敏。 + +### 后台任务 + +| 任务 | 周期 | 说明 | +| --- | --- | --- | +| 每日运势生成 | 每日 0 点 + 启动兜底 | 大模型生成十二星座运势;Redis 分布式锁保证多实例仅执行一次;当日不足 12 条时按需兜底生成 | +| 订单超时过期 | 每 60 秒 | 将超过 15 分钟未支付的订单批量置为"已过期" | +| 权益发放补偿 | 每 60 秒 | 重试发放失败的权益任务(恢复僵死任务、幂等防重复发放、超限告警) | + +## 部署提示 + +- **工作目录**:服务启动时必须位于项目根目录(配置路径固定为 `configs/config.yaml`),容器化部署时注意挂载配置文件与日志目录。 +- **多实例**:支持水平扩展;写操作幂等保护与关键后台任务(运势生成、权益补偿)均通过 Redis 分布式锁协调。 +- **推理服务**:`thirdparty/` 下的两个 Python 服务建议独立部署(GPU 优先),并通过 `MattingModel` / `ClassificationModel` 配置段指向其地址。 +- **可观测性后端**:按需配置 `Otel.ExporterEndpoint` 指向 OTLP Collector;无需可观测性时设置 `Enabled: false` 即可零开销关闭。 + +## 安全注意事项 + +- `JWT.SecretKey` 必须设置为强随机值,代码会强制校验,为空直接启动失败;请勿使用默认占位值。 +- `WechatPay.APIv3Key` 必须为 32 字节随机密钥;微信支付/退款回调依赖官方 SDK 验签与解密,勿绕过。 +- 所有密钥仅存放于 `configs/config.yaml`(已被 `.gitignore` 忽略),严禁提交到版本库或写入数据库。 +- 后端对 AI 大模型输入(衣物信息、用户数据)做了清洗与截断,防 prompt 注入;上传文件除扩展名外还校验 magic bytes,防伪造类型。 +- 生产环境请将 `Server.RunMode` 设为 `release`,并通过反向代理对外提供 HTTPS。 diff --git a/Stitch.md b/Stitch.md new file mode 100644 index 0000000..21d038d --- /dev/null +++ b/Stitch.md @@ -0,0 +1,152 @@ +> **使用说明(本段不要粘贴)**:本文件是给 Google Stitch 的**分段粘贴**提示词,共 15 段。请按段号从小到大、**每段单独复制一次**依次粘贴发送给 Stitch:复制范围=该段标题行(`## 第 N 段|…`)**以下**、下一段标题行**以上**的正文,不要包含段标题行。 +> **第 1 段必须先发**,它是全局设计基准;第 2~15 段每页一段,每段开头自带「上下文锚点」,保证分次发送时跨页风格一致。请在**同一轮对话**中按顺序发送,不要一次性粘贴多段,也不要跳段。 + +--- + +## 第 1 段|全局规范与设计语言(必须先发) + +你是一位资深的 Apple 生态产品设计师,精通 iOS 与微信小程序的界面规范。接下来我要让你为一款名为「纸条AI衣橱」的**微信小程序**设计全套高保真 UI 界面原型(共 14 个页面:13 个主页面 + 「创建/编辑穿搭」)。 + +我会在本条消息给出**全局规范**,随后**逐页单独发送**每一屏的详细描述,请你逐屏生成,并保证全部 14 页风格、组件、配色完全一致。请先阅读并记住以下全局规范,本轮**不要生成任何页面**,只回复「已理解全局规范,请发送第 1 屏」。 + +【产品定位】这是一款面向年轻女性用户的 AI 衣橱管理工具:用户拍照上传自己的衣物,系统自动抠图并识别衣物的 15 个属性维度(大类、小类、风格、主色、副色、厚度、版型、材质、花纹、袖型、衣长、领型、正式程度、适用场合、视觉冲击力);随后用户可以自由组合衣物创建穿搭、一键生成 AI 穿搭推荐(结合当日星座运势)、把整套衣橱或精选衣物分享给好友、在社区公开自己的穿搭并收获点赞与收藏,还可以购买会员或额度包解锁更多上传、AI 推荐与分享次数。产品气质应当是清爽、克制、值得信赖的私人衣橱助手,而不是电商导购。 + +【平台与画布约束】所有界面均为微信小程序(WeChat Mini Program),不是 Web 页面,也不是原生 App。画布按 iPhone 逻辑分辨率 375×812pt 设计(即 750rpx 宽),尺寸请用 rpx 思维表达。每个页面顶部必须有微信原生导航栏:居中显示页面标题,右上角固定保留微信胶囊按钮(圆角边框内的「···」与「⊙」)的位置,任何自定义图标都不得覆盖胶囊区域;二级页面左上角为原生返回箭头。底部 tabBar 为微信小程序标准样式:高度约 50pt,半透明毛玻璃(frosted material)底 + 顶部 0.5px 发丝分隔线,每个 tab 为「SF Symbols 风格线性图标 + 中文文字」,选中态为系统蓝 #007AFF、未选中为系统灰 #8E8E93,共 5 个:衣橱、穿搭、运势、通知、我的。全局不得出现浏览器地址栏、汉堡菜单、网页式页脚。 + +【设计语言】整体遵循 Apple 人机界面指南(HIG)的 iOS 视觉语言:干净、克制、以内容为中心。页面背景统一为 iOS 系统分组灰 #F2F2F7,内容一律承载在纯白 #FFFFFF 卡片中,卡片圆角 16pt 并采用 inset 分组列表(分组之间留 16pt 间距,分组内列表行高 44pt,用 0.5px 发丝级分隔线 #E5E5EA 且分隔线左缩进 16pt 与文字左对齐),不使用彩色阴影与重投影;层次靠背景色、毛玻璃与发丝分隔线区分,避免渐变与强装饰。强调色使用 iOS 系统蓝 #007AFF,辅助色使用系统靛蓝 #5856D6,情感化元素(点赞、收藏、喜欢)使用系统粉 #FF2D55;文字使用系统标签色:主要文字 #000000(label)、次要文字 #8E8E93(secondaryLabel)、占位与弱提示 #C7C7CC(tertiaryLabel);功能色严格使用 Apple 系统色:绿 #34C759、橙 #FF9500、红 #FF3B30、蓝 #007AFF、黄 #FFCC00。字体使用 SF Pro Text / Display 与中文苹方(PingFang SC):iOS 大标题 34px/700 左对齐(上滑时收进导航栏变为 17px 居中)、区块标题 17px/600、正文 15px/400、辅助说明 13px/400、最小注释 11px;金额与时间使用等宽数字(tabular figures)。图标统一采用 SF Symbols 风格:线性、2px 圆角描边、字重与文字协调、单一颜色,不使用多色拟物图标。控件遵循 iOS 规范:主操作为系统蓝填充按钮(圆角 12pt、白字、按下时降低不透明度),次操作为浅灰 #F2F2F7 无边框按钮或系统蓝文字按钮,危险操作为系统红文字,禁用态文字与底色同时降透明度;开关为 iOS 绿色开关,分段控件为灰底滑块,滑块为细轨圆点。导航栏与标签栏使用半透明毛玻璃材质(frosted material)并带 0.5px 发丝分隔线;留白遵循 8pt 栅格,页面左右安全边距 16pt。所有衣物图片为白底或透明底的单件商品图,居中展示在 #F2F2F7 圆角方块内,图片圆角与卡片一致。 + +【导航结构】5 个 tabBar 页面:衣橱(衣物网格 + 上传入口)、穿搭(我的穿搭 + AI 推荐入口)、运势(今日星座运势)、通知(消息列表)、我的(个人资料、额度、会员、订单、分享管理)。二级页面包括:衣物上传、衣物详情与编辑、穿搭详情、AI 穿搭推荐、衣柜分享(列表 / 创建 / 详情)、额度与会员商城、订单列表、编辑资料。 + +【页面跳转关系】登录成功进入「衣橱」;衣橱右下悬浮按钮 → 衣物上传 → 处理完成自动进入衣物详情;点击衣物卡片 → 衣物详情;穿搭页点击卡片 → 穿搭详情,点「AI 推荐」→ AI 穿搭推荐,保存后回到穿搭详情;创建穿搭从衣橱多选衣物后进入;运势页「生成今日推荐穿搭」→ AI 穿搭推荐;我的页 → 额度与会员商城 / 订单 / 我的分享 / 我的收藏 / 额度明细 / 编辑资料;我的分享 → 创建分享 → 分享详情;通知项点击后标记为已读;商品购买 → 订单确认 → 订单列表。所有二级页面左上角为返回箭头并支持右滑返回。 + +【图标与组件规格】图标采用 SF Symbols 语义:衣橱 = hanger、运势 = sparkles、通知 = bell、我的 = person.crop.circle、上传 = camera、点赞 = heart、收藏 = star、分享 = square.and.arrow.up、删除 = trash、编辑 = pencil、会员 = crown、订单 = doc.text、额度 = gauge、设置 = gear、返回 = chevron.left。尺寸:tabBar 图标 24pt,行内图标 20pt,卡片大图标 28pt,头像 40~64pt 圆形。按钮:主按钮高 48pt、圆角 12pt、整宽时左右留 16pt;次按钮高 44pt;输入框高 44pt、圆角 10pt、内边距 12pt、占位文字用 #C7C7CC。布局:卡片内边距 16pt、卡片间距 12pt、分组间距 16pt、页面左右安全边距 16pt、底部安全区 34pt;衣物网格为两列,列间距与行间距均 12pt,卡片圆角 16pt,图片区 1:1,列表行高 44pt,分隔线 0.5px #E5E5EA。 + +【全局通用元素】状态徽标统一为小圆角胶囊标签(10~11px,系统色 10% 浅底 + 同色深字),颜色取系统灰 / 系统蓝 / 系统橙 / 系统绿 / 系统红。空态统一为「居中 SF Symbols 风格线稿插画(系统蓝线条 + 淡蓝 #E8F2FF 块面)+ 17px 加粗主文案 + 13px 系统灰说明 + 一个系统蓝填充按钮或系统蓝文字引导」。加载态:首屏为骨架屏(#F2F2F7 与 #E5E5EA 圆角块轻微呼吸动画),按钮内为 iOS 风格活动指示器,下拉刷新为微信小程序标准三点动画。轻提示为毛玻璃圆角浮层(白色 80% 底 + 深色文字,圆角 12pt,1.5 秒后自动消失);确认提示为 iOS 风格居中 Alert(毛玻璃白底、圆角 14pt、标题 17px 加粗 + 说明 13px、按钮横向平分,主按钮为系统蓝加粗文字,取消为常规字重,危险操作为系统红 #FF3B30);底部面板为 iOS sheet(顶部居中 36×5pt 灰色 grabber 小横条,顶部圆角 10pt,毛玻璃白底,取消项单独成组并与其他项间隔 8pt)。衣物图未就绪时显示 #F2F2F7 圆角方块 + 居中线性衣架图标。金额统一展示为「¥ + 两位小数」并使用等宽数字(后端单位为分,前端需除以 100),原价用系统灰删除线。时间统一展示为「刚刚 / x 分钟前 / 今天 / 2025-04-15」(后端为 10 位秒级时间戳)。所有列表支持下拉刷新与上拉加载,每页最多 50 条。页面跳转使用微信小程序标准的右滑返回手势与原生导航返回,转场为 iOS 风格的右推与右滑。 + +【负面约束】不要出现 Material Design 风格(带阴影的圆形浮动按钮、汉堡菜单、黑色 Snackbar)、不要用 emoji 充当功能图标、不要出现英文界面文案(金额符号与 SF Symbols 名称除外)、不要使用真实照片或任何品牌 Logo、不要使用浓烈渐变、霓虹色、拟物质感与彩色投影、不要出现网页式页脚与浏览器地址栏、不要在右上角胶囊区域放置任何自定义按钮、不要虚构后端不存在的字段与能力(穿搭封面图由多件衣物图拼接、不展示收藏数与穿搭创建时间、不展示穿搭作者信息、通知不展示发送者头像、不提供「全部已读」、商品不展示图片与标签、不提供衣物搜索与排序、分享不展示标题与访问量、运势只有综合/爱情/工作/财富四项评分)。 + +【后续生成约定】 +1. 我接下来会按「登录 → 衣橱 → 衣物详情 → 穿搭 → AI 穿搭推荐 → 衣物上传 → 穿搭详情 → 运势 → 衣柜分享 → 通知 → 额度与会员商城 → 订单 → 我的 → 创建/编辑穿搭」的顺序,每屏单独发一条消息,共 14 屏(13 个主页面 + 「创建/编辑穿搭」补充页)。 +2. 每屏输出为一个独立画板(竖版 375×812pt),画板顶部标注中文页面名。 +3. 收到某屏描述后**只输出该屏**,不要重复输出其他页面,不要复述我的描述。 +4. 同一组件(按钮、标签、卡片、列表行、弹窗、空态)在各页保持完全一致。 +5. 各屏描述中已内联该屏的空态、加载态与异常态,请在同一画板旁一并生成。 +6. 所有文案使用简体中文;图片位置使用 #F2F2F7 占位或简约线稿,不要出现真实照片。 + +请回复「已理解全局规范,请发送第 1 屏」,我将逐屏发送页面描述。 + +--- + +## 第 2 段|第 1 屏 微信授权登录 + +【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,顶部微信原生导航栏 + 右上胶囊按钮占位,iOS 系统蓝 #007AFF 为强调色,#F2F2F7 分组灰底 + 纯白卡片 + 0.5px #E5E5EA 发丝分隔线,SF Symbols 风格线性图标,SF Pro / 苹方字阶,左右安全边距 16pt。本条为第 1 屏,请只生成本页一个画板(画板顶部标注「微信授权登录」),并一并生成其中的空态/异常态。 + +全屏页面,无顶部导航标题。顶部 1/3 为淡蓝 #E8F2FF 块面托起一张轻盈的线稿插画:衣架、连衣裙、T 恤、鞋子错落摆放,线条用系统蓝 #007AFF、块面用 #E8F2FF 与 #5856D6 点缀。中部为线性衣架图标组成的产品 Logo 与产品名「纸条AI衣橱」,下方一句 slogan「把你的衣橱,交给 AI 打理」。底部为安全区上方的整宽圆角 14pt 填充按钮「微信一键登录」(按钮内左侧带微信图标),按钮正下方一行 11px 灰色小字「登录即代表同意《用户协议》和《隐私政策》」,其中协议名为可点击的系统蓝文字。页面不要出现手机号输入框、验证码、账号密码等任何登录方式。 + +--- + +## 第 3 段|第 2 屏 衣橱(tabBar) + +【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,iOS 系统蓝 #007AFF 强调色、#F2F2F7 页面底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标,两列衣物网格(列间距与行间距 12pt、卡片圆角 16pt、图片区 1:1)。本条为第 2 屏「衣橱」,属于 5 个 tabBar 页之一(衣橱 / 穿搭 / 运势 / 通知 / 我的,本页为选中态),请只生成本页一个画板。 + +顶部导航标题「我的衣橱」。标题下方第一行是可横向滑动的分类筛选标签:全部、上装、下装、连衣裙、外套、鞋、配饰(选中为系统蓝实心底白字,未选中为浅灰底深灰字);第二行是一行 11px 灰色统计小字「共 128 件 · 上传额度剩余 13 次」。主体为两列等宽衣物卡片网格:每张卡片为白底圆角,上部为 1:1 浅灰圆角图区,透明底衣物图居中;图区右上角为处理状态徽标——排队中(系统灰)、AI 抠图中(系统蓝)、智能分类中(系统蓝)、识别失败(系统红,点击可重试);下部左侧为衣物名「白色纯棉T恤」(单行截断),下方灰色小字「上装 · T恤 · 白色」。右下角为悬浮圆形系统蓝按钮(内为加号),点击上传衣物。空态:中央衣架线稿插画 + 主文案「衣橱还空着」+ 说明「拍照上传第一件衣物,AI 自动抠图并识别属性」+ 系统蓝按钮「上传衣物」。加载态:两列骨架卡片(灰块呼吸动画)。列表支持下拉刷新与上拉加载更多。 + +--- + +## 第 4 段|第 4 屏 衣物详情与编辑 + +【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角原生返回箭头、支持右滑返回),顶部微信原生导航栏 + 右上胶囊占位,iOS 系统蓝 #007AFF,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,inset 分组列表(行高 44pt、分隔线左缩进 16pt)。本条为第 4 屏,请只生成本页一个画板,并同时给出「浏览态」与「编辑态」两个版本。 + +二级页面,标题「衣物详情」,标题下方右侧放一个「编辑」文字按钮。顶部为占屏约 45% 的浅灰圆角大图区,透明底衣物图居中展示。下方白色卡片:第一行为衣物名「白色纯棉T恤」大字(编辑态为输入框,右侧「保存」按钮);下面是属性列表,按分组以「左侧字段名 + 右侧值」的行式呈现,整行可点击并在底部弹起选择器或输入框,正式程度与视觉冲击力两项用 5 段小进度条加数字「3/5」「4/5」展示,主色一项在文字右侧跟一个同色小色块:大类(上装)、小类(T恤)、风格(简约)、主色(白色)、副色(—)、厚度(薄)、版型(宽松)、材质(纯棉)、花纹(纯色)、袖型(短袖)、衣长(常规)、领型(圆领)、正式程度(3/5)、适用场合(休闲、通勤,标签形式)、视觉冲击力(4/5);再下方是备注区「备注:去年夏天买的,领口略松」。页面底部为红色文字按钮「删除这件衣物」,点击弹确认框「删除后将退回 1 次上传额度,确认删除?」+「取消」「确认删除」(红色)。注意额度回退规则:仅「处理中 / 已成功」的衣物删除时回退 1 次上传额度;「识别失败」的衣物在失败时已自动回退,删除时不再返还,弹窗文案应改为「确认删除?」(不提返还)。 + +--- + +## 第 5 段|第 5 屏 穿搭(tabBar) + +【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,iOS 系统蓝 #007AFF 强调色、辅助靛蓝 #5856D6、情感粉 #FF2D55,#F2F2F7 底 + 纯白卡片(圆角 16pt)+ 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(点赞 = heart、收藏 = star)。本条为第 5 屏「穿搭」,属于 5 个 tabBar 页之一(本页为选中态),请只生成本页一个画板。 + +顶部导航标题「穿搭」,下方为分段控件:我的穿搭 / 已收藏(选中段为白底系统蓝字滑块)。其下右侧一行两个入口:描边小按钮「+ 创建穿搭」与系统蓝小按钮「AI 推荐」。主体为纵向卡片流:每张穿搭卡片为白底圆角,顶部为「拼图式」封面——把该穿搭包含的衣物抠图并排错落摆放在浅灰圆角横图上(示例 4 件,最多 10 件;若只有一件则居中放大);下方左侧为穿搭名「初秋通勤三件套」,右侧为状态标签:仅自己可见(系统灰)、已公开(系统蓝)、AI 推荐(系统靛蓝 #5856D6);第二行为穿搭描述「白T配直筒牛仔裤,清爽干净,适合初秋通勤」两行截断;第三行为场景标签「通勤」+ 灰色小字「4 件衣物」;卡片底部右侧为点赞图标与数量「128」、收藏图标(未收藏为线框、已收藏为系统蓝实心)。点击卡片进入穿搭详情。空态:插画 + 「还没有穿搭」+「从衣橱挑几件,搭一套吧」+ 系统蓝按钮「创建穿搭」;收藏 tab 空态为「还没有收藏的穿搭」。 + +--- + +## 第 6 段|第 7 屏 AI 穿搭推荐 + +【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、支持右滑返回),iOS 系统蓝 #007AFF 强调色、辅助靛蓝 #5856D6,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标,底部固定操作栏位于安全区上方(白底 + 上分割线)。本条为第 7 屏,请生成本页画板,并一并生成「场景选择弹窗」「生成中状态页」「结果页」与三种异常态。 + +从穿搭页「AI 推荐」按钮进入。进入后首先是场景选择半屏弹窗(可跳过,默认「每日推荐」):两个大卡片单选——每日推荐(按今日运势与衣橱自动搭配)与自定义场景(下方出现输入框,占位文案「例如:海边度假、周一例会、初次约会」),底部系统蓝按钮「开始生成(消耗 1 次推荐额度)」。生成中为全屏状态页:中央是衣物线稿轮播的加载动画,下方文案「AI 正在为你搭配…」+ 灰字「结合今日白羊座运势与你的 128 件衣物」。生成完成后展示结果页:顶部为推荐组合的大幅拼图(4~6 件衣物),下方白卡为 AI 生成的标题「晴日轻复古通勤组合」+ 推荐文案「今日综合运势 4 星,适合以米白针织开衫搭配直筒牛仔裤,清爽又有层次」+ 场景标签「通勤」;再下方是「推荐单品」横向列表(每件为小方图 + 名称 + 小类,如「米白针织开衫 · 上装」)。底部固定栏:系统蓝按钮「保存到我的穿搭」+ 描边按钮「换一套」(下方一行 11px 灰字「换一套将再消耗 1 次推荐额度」;受 180 秒并发限制,上一套生成后 180 秒内点击不发起请求,仅轻提示「正在生成中,请稍候」并禁用按钮)。保存成功后 轻提示「已保存到我的穿搭」并跳转穿搭详情;保存前该穿搭标注标签「待保存」。异常态有三种:衣橱为空或无可用衣物 → 插画 +「还没有可用衣物」+「先上传几件处理成功的衣物吧」+ 按钮「去上传」;额度不足 → 弹窗「AI 推荐额度不足」+ 按钮「去购买」;请求过于频繁(180 秒内重复生成,返回 429)→ 轻提示「正在生成中,请稍候」并禁用「换一套」与「开始生成」按钮。 + +--- + +## 第 7 段|第 3 屏 衣物上传 + +【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、支持右滑返回),iOS 系统蓝 #007AFF 强调色、系统红 #FF3B30 危险色,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(上传 = camera),底部面板为 iOS sheet(36×5pt grabber、圆角 10pt、取消项单独成组间隔 8pt)。本条为第 3 屏,请生成本页画板,并一并生成「初始态」「处理中」「失败态」「额度不足弹窗」与「底部动作面板」。 + +二级页面,标题「上传衣物」。初始态为一块占屏约 40% 的白色虚线圆角上传区,中央为线性相机图标 + 文案「拍照或从相册选择」+ 灰色小字「支持 JPG / PNG,单张不超过 20MB,上传消耗 1 次上传额度」,区域下方为一行灰色说明「AI 将自动抠图并识别 15 项属性」。点击后从底部弹起半屏动作面板:拍照、从相册选择、取消(白色圆角上滑面板,取消项与其他项之间用 8pt 灰间隔)。选择图片后进入处理中状态:顶部为所选图片缩略(圆角),中部为三步横向步骤条「排队中 → AI 抠图 → 智能分类」,当前步骤圆点为系统蓝实心、已完成为对勾、未开始为浅灰;下方文案「AI 正在识别你的衣物…」与小字「通常需要 10 秒左右,请稍候」。处理成功后自动跳转衣物详情;失败态为红色感叹号图标 + 「识别失败:图片抠图失败」+ 灰字「本次上传额度已自动退回」+ 两个按钮「重新上传」(系统蓝)与「手动填写信息」(描边)。若上传前额度为 0:居中弹窗「上传额度不足」+ 说明「当前剩余 0 次,购买额度包后可继续上传」+ 按钮「去购买」(系统蓝)与「取消」。 + +--- + +## 第 8 段|第 6 屏 穿搭详情 + +【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、右上「更多」入口),iOS 系统蓝 #007AFF、情感粉 #FF2D55、辅助靛蓝 #5856D6,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(点赞 = heart、收藏 = star、分享 = square.and.arrow.up),底部固定操作栏位于安全区上方。本条为第 6 屏,请生成本页画板,并一并给出「属主视角」「他人浏览视角」两个版本与「公开确认弹窗」。 + +二级页面,标题为穿搭名。顶部为大幅拼图封面(白底圆角,多件衣物横排错落)。下方白色卡片:穿搭名 + 场景标签「通勤」+ 来源标签(自建 / AI 推荐)+ 公开状态(仅自己可见 / 已公开);描述文案;「包含衣物」横向滚动小卡列表(每件为小方图 + 名称,点击可进入该衣物详情)。底部为固定操作栏(安全区上方白底 + 上分割线):左侧爱心点赞按钮(含数量)与星形收藏按钮,右侧为「公开到社区」系统蓝按钮(已公开时变为描边按钮「取消公开」)与分享图标按钮。点击「公开到社区」弹确认框「公开后将展示在社区供他人点赞收藏,并消耗 1 次社区分享额度」+「取消」「确认公开」。他人浏览公开穿搭时,底部操作栏只保留点赞与收藏。删除与编辑入口放在页面右上「更多」弹出的动作面板中(编辑穿搭、删除穿搭)。 + +--- + +## 第 9 段|第 8 屏 运势(tabBar) + +【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,iOS 系统蓝 #007AFF、淡蓝块面 #E8F2FF,#F2F2F7 底 + 纯白卡片(圆角 16pt)+ 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(运势 = sparkles),等宽数字。本条为第 8 屏「运势」,属于 5 个 tabBar 页之一(本页为选中态),请只生成本页一个画板,并一并生成加载态与异常态。注意:运势只有综合 / 爱情 / 工作 / 财富四项评分与幸运色、幸运数字,不要出现其他评分维度;星座由用户生日推算,**不提供「切换星座」能力**,右上角只放「去设置生日」文字入口(跳转编辑资料)。 + +顶部导航标题「每日运势」。页面主体是一张淡蓝 #E8F2FF 圆角大卡(不要浓烈渐变):顶部左侧为星座线性图标与星座名「白羊座」,右侧为日期「2025.04.15」与文字按钮「去设置生日」;中部为「综合运势」标签 + 超大数字「4.0」+ 一排 5 星(4 星为实心系统蓝、1 星为浅灰线框);下方为三项评分行,每行为「名称 + 5 段小进度条 + 分数」:爱情运势 3/5、工作运势 5/5、财富运势 4/5(进度条填充系统蓝、未填充为浅灰);再下方为白色小卡「今日运势」+ 文案「今天适合主动出击,贵人运旺,适合尝试新的穿搭风格」;底部并排两张小卡:幸运色(左侧一个红色小圆点 + 「红色」)与幸运数字(「6」)。页面底部为描边按钮「生成今日推荐穿搭」(跳转 AI 推荐页)。异常态:用户未设置生日 → 插画 + 「还没有设置生日」+「设置生日后即可查看你的星座运势」+ 系统蓝按钮「去完善资料」。加载态为整卡骨架屏;若返回「运势生成中」(首次无缓存异步生成),保持骨架屏并轻提示「运势生成中,请稍后重试」,3~5 秒后自动重试一次。 + +--- + +## 第 10 段|第 9 屏 衣柜分享(3 个子页面) + +【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、支持右滑返回),iOS 系统蓝 #007AFF,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标,两列衣物网格(间距 12pt、圆角 16pt、图片区 1:1),底部固定栏位于安全区上方。本条为第 9 屏,包含 ①②③ 三个子页面,请生成三个画板,分别标注「衣柜分享 - 我的分享」「衣柜分享 - 创建分享」「衣柜分享 - 分享详情」,并一并生成其中的有效期面板、分享预览卡片与失效态。 + +①「我的分享」列表页(从我的页进入,标题「衣柜分享」):右上角下方为「+ 新建分享」描边按钮;列表项为白卡,左侧为衣物小图组成的 2×2 预览网格,右侧第一行「衣柜分享 · 12 件」,第二行类型标签(部分衣物 / 全量衣橱),第三行为有效期「3 天后过期」或「长期有效」;已取消或已过期的卡片整体降透明度并在右上角加盖灰色角标「已取消」「已过期」。 + +②创建分享页:第一步为两个大卡片单选——部分衣物(说明「挑选几件衣物分享给好友」)与全量快照(说明「分享当前衣橱的全部衣物」);若选部分,进入衣物多选网格(勾选态为卡片系统蓝描边 + 右上系统蓝对勾),底部固定栏显示「已选 6 件」+ 系统蓝按钮「下一步」;随后底部弹起有效期面板:1 天 / 7 天 / 30 天 / 长期有效(单选,选中右侧对勾),底部按钮「确认分享(消耗 1 次衣柜分享额度)」。分享成功后展示分享卡片预览:小程序卡片样式,顶部为分享者头像与昵称「小雨 向你分享了 12 件衣物」,中部为衣物预览横图,底部为「查看衣橱」按钮。 + +③分享详情页(访客或属主视角):顶部为分享者头像 + 昵称 + 「分享了 12 件衣物」与有效期提示,下方为只读衣物网格;属主视角底部固定栏为「取消分享」红色文字按钮,访客视角无操作栏。分享已失效时整页为失效态:灰色插画 +「该分享已过期或已被取消」+ 描边按钮「返回首页」。 + +--- + +## 第 11 段|第 10 屏 通知(tabBar) + +【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,iOS 系统蓝 #007AFF、系统红 #FF3B30,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(通知 = bell),未读标记为 6pt 系统蓝小圆点。本条为第 10 屏「通知」,属于 5 个 tabBar 页之一(本页为选中态,tabBar 通知图标右上角带红色未读数徽标「7」),请只生成本页一个画板,并一并生成空态。注意:通知不展示发送者头像,不提供「全部已读」入口。 + +顶部导航标题「通知」,下方为筛选标签:全部、点赞、订单、系统。列表项为白卡:左侧 40pt 圆形线性图标——点赞为心形(浅粉底 #FFE8EC 红字 #FF3B30)、订单为票据(淡蓝底 #E8F2FF 系统蓝)、系统为铃铛(浅灰底 #F2F2F7 灰字);右侧第一行为标题「收到新的点赞」加粗,右上角为时间「2 小时前」,第二行为内容「你的穿搭「周末休闲通勤套」收到了一个赞」两行截断。未读项在标题左侧带一个 6pt 系统蓝小圆点且卡片底部有一条极浅系统蓝左边线,已读项文字整体偏灰。tabBar 的通知图标右上角带红色未读数徽标「7」。点击通知项进入「通知详情」二级页面(标题「通知详情」,左上角返回箭头):白卡内自上而下为类型圆形图标 + 加粗标题「收到新的点赞」+ 灰色时间「2025-04-15 14:32」+ 完整内容正文「你的穿搭「周末休闲通勤套」收到了一个赞」,进入的同时将该条标记为已读(列表红点消失、文字转灰);不展示发送者头像,不提供「全部已读」入口。空态:铃铛线稿插画 +「还没有消息」+「穿搭被点赞、订单动态都会出现在这里」。 + +--- + +## 第 12 段|第 11 屏 额度与会员商城 + +【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、支持右滑返回),iOS 系统蓝 #007AFF、警示橙 #FF9500,#F2F2F7 底 + 纯白卡片(圆角 16pt)+ 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(会员 = crown、上传 = camera、AI = sparkles、衣柜分享 = 衣柜、搭配分享 = hanger),金额展示为「¥ + 两位小数」等宽数字、原价为系统灰删除线。本条为第 11 屏,请生成本页画板,并一并生成「订单确认」子页面。注意:商品不展示图片与标签,只展示名称、描述、售价与原价。 + +从我的页进入,标题「额度与会员」。顶部为会员卡(淡蓝 #E8F2FF 底、系统蓝文字的圆角大卡):左侧为皇冠线性图标 + 「月度会员」+ 灰字「有效期至 2025-05-15」,右侧为「立即续费」描边按钮;体验会员(新用户注册赠送,通常 3 天)时显示「体验会员」+ 灰字「3 天后到期」;非会员时显示「开通会员,解锁更多 AI 与上传权益」+ 系统蓝按钮「立即开通」。下方为「我的额度」四个小卡组成的 2×2 网格,每卡为白底圆角:名称为「上传额度 / AI 推荐额度 / 社区分享额度 / 衣柜分享额度」,中部为剩余数大字「13」+ 灰字「/ 20」,底部为细进度条(已用比例,系统蓝填充;剩余低于 20% 时进度条转为警示橙 #FF9500)。额度网格下方为「看广告得额度」入口条(白底圆角横条,左侧为 SF Symbols 风格「播放」线性图标 + 文案「看广告得额度」,右侧为「看广告 +N」小系统蓝描边按钮,N 由服务端配置固定):点击弹出微信激励视频广告(小程序原生广告组件),完整观看后自动调后端 `/ad-reward/grant`(前端只传奖励类型与广告记录 ID,不传金额),成功后对应额度余量实时 +N 并轻提示「已到账 +N 额度」;广告未看完或中途关闭不发放;服务端未开启广告激励时整个入口条不展示。加载态:广告加载中按钮内为 iOS 活动指示器;失败态:轻提示「广告加载失败,请稍后重试」。其下为「额度明细」入口行(右侧箭头,进入流水列表:每条为「变更原因 + 数量(-1 / +30)+ 变更后剩余 + 时间」)。再下方为「额度与会员充值」商品区,顶部为类型标签:全部、会员包、上传包、AI 推荐包、衣柜分享包、搭配分享包。商品卡为白底圆角:左侧为类型图标(会员=皇冠、上传=相机、AI=星星、衣柜分享=衣柜、搭配分享=衣架),中部第一行为商品名「月度会员」,第二行为描述「开通后 30 天内无限使用 AI 推荐」灰色小字,右侧为售价「¥19.00」(系统蓝 15px 加粗)与原价「¥29.00」(灰色划线),下方右侧为小系统蓝按钮「购买」。点击购买进入订单确认:商品摘要卡(名称 + 权益说明)+ 金额行「实付 ¥19.00」+ 底部系统蓝按钮「微信支付」。 + +--- + +## 第 13 段|第 12 屏 订单 + +【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、支持右滑返回),iOS 系统色:警示橙 #FF9500、成功绿 #34C759、系统灰 #8E8E93、系统蓝 #007AFF,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线,SF Symbols 线性图标(订单 = doc.text),金额「¥ + 两位小数」等宽数字。本条为第 12 屏,请生成本页画板,并一并生成「微信支付面板」「申请退款面板」与空态。 + +顶部导航标题「我的订单」,下方为状态筛选标签:全部、待支付、已支付、已取消、已过期、已退款。列表项为白卡:第一行左侧为订单号尾部「订单 …8b」灰色小字,右侧为状态徽标——待支付(警示橙)、已支付(成功绿)、已取消(浅灰)、已过期(系统灰)、已退款(系统蓝);第二行为商品名「月度会员」+ 权益灰字「30 天会员」;第三行左侧为实付金额「实付 ¥19.00」,右侧为操作按钮:待支付显示倒计时灰字「剩余 14:32」+ 描边按钮「取消订单」+ 系统蓝按钮「去支付」;已支付显示描边按钮「申请退款」;其余状态无按钮。点击「去支付」从底部弹起微信支付面板(金额「¥19.00」+ 商品名 + 绿色「确认支付」按钮)。申请退款弹出面板:退款原因输入框(占位「请填写退款原因」,上限 200 字)+ 提交按钮,提交后 轻提示「退款申请已提交,等待审核」,该订单出现「退款中」提示。空态:票据线稿插画 +「还没有订单」+ 描边按钮「去看看额度包」。 + +--- + +## 第 14 段|第 13 屏 我的(tabBar) + +【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,iOS 系统蓝 #007AFF、警示橙 #FF9500,#F2F2F7 底 + 纯白卡片 + 0.5px #E5E5EA 发丝线(分隔线左缩进 16pt 与文字对齐),inset 分组列表行高 44pt,SF Symbols 线性图标,头像 40~64pt 圆形。本条为第 13 屏「我的」,属于 5 个 tabBar 页之一(本页为选中态),是最后一屏。请生成本页画板,并一并生成「编辑资料」子页面与顶部额度提示条。 + +顶部为个人信息白卡:左侧 64pt 圆形头像(未设置时为灰色人像占位),右侧第一行为昵称「小雨」+ 性别图标,第二行为两个小标签:会员标签(系统蓝浅底)——非会员时不展示该标签,体验会员展示「体验会员 · 3 天后到期」(新用户注册赠送,通常 3 天),月度/年度会员展示「月度会员 · 05-15 到期」;与星座「白羊座」(浅灰底,未设置生日时显示可点击的「设置生日」);卡片右侧为「编辑资料 >」文字按钮。下方第一组功能列表(白卡,每行线性图标 + 文字 + 右侧说明 + 箭头):我的额度(右侧灰字「上传额度剩 13」)、我的订单、我的分享、我的收藏、额度明细。第二组:编辑资料、设置生日、客服与反馈、关于纸条AI衣橱。底部为灰色小字版本号「v1.0.0」。编辑资料页为表单:头像(点击更换)、昵称(≤32 字)、性别(男 / 女)、生日(日期选择器,不可晚于今天)、手机号(只读展示),底部系统蓝按钮「保存」。额度不足或会员到期前,在「我的」页顶部显示一条浅橙提示条「你的上传额度仅剩 2 次,点击购买额度包 >」。 + +--- + +## 第 15 段|补充页 创建 / 编辑穿搭 + +【上下文锚点】沿用第 1 段全局规范:微信小程序「纸条AI衣橱」,竖版 375×812pt,二级页面(左上角返回箭头、支持右滑返回),iOS 系统蓝 #007AFF 强调色、系统红 #FF3B30 危险色,#F2F2F7 底 + 纯白卡片(圆角 16pt)+ 0.5px #E5E5EA 发丝线,输入框高 44pt 圆角 10pt、占位文字 #C7C7CC,两列衣物网格(间距 12pt、卡片圆角 16pt、图片区 1:1),底部固定栏位于安全区上方(白底 + 上分割线),轻提示为毛玻璃圆角浮层。本条为最后一个补充页,请生成「创建穿搭」「编辑穿搭」两个版本(表单与多选网格一致,编辑态预填已有内容),并一并给出空态与轻提示异常态。 + +从穿搭页「+ 创建穿搭」按钮进入(或从穿搭详情右上「更多」→「编辑穿搭」进入),标题为「创建穿搭」(编辑态为「编辑穿搭」)。页面顶部为拼图预览区:白底圆角横图内把已选衣物抠图并排错落摆放,未选择任何衣物时显示 #F2F2F7 占位 + 居中线性衣架图标 + 灰字「还没有选择衣物」。其下为白色表单卡片:第一行为「穿搭名称」输入框(必填,上限 32 字,占位「给这套穿搭起个名字」,示例「初秋通勤三件套」);第二行为「穿搭描述」多行输入框(上限 500 字,占位「简单描述这套穿搭」,右上角显示字数「12/500」);第三行为「适合场景」输入框(上限 100 字,占位「例如:通勤、约会、休闲」,可展示为已选场景标签 + 「+ 添加」)。表单下方为「选择衣物」分组(白卡,行式入口,右侧显示「已选 4 件」与箭头):点击进入衣橱多选网格(复用第 2 屏的两列衣物卡片,勾选态为卡片系统蓝描边 + 右上系统蓝对勾,最多 10 件、至少 1 件),底部固定栏显示「已选 4 件」+ 系统蓝按钮「确定」。底部固定操作栏:系统蓝按钮「保存」(未填名称或未选衣物时为禁用态,文字与底色同时降透明度),编辑态在按钮上方另加一行红色文字按钮「删除穿搭」(点击弹确认框「确认删除这套穿搭?」+「取消」「确认删除」(红色))。异常态用轻提示:未选任何衣物点保存 →「至少选择 1 件衣物」;选择超过 10 件 →「最多选择 10 件衣物」;名称为空 →「请填写穿搭名称」。保存成功后 轻提示「已保存」并返回穿搭详情。 diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..750fd41 --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,118 @@ +// Command migrate 是独立的数据库结构迁移入口:手动执行,服务启动不会触发迁移。 +// +// 用法: +// +// go run ./cmd/migrate up # 执行到最新版本 +// go run ./cmd/migrate down # 全量回滚 +// go run ./cmd/migrate steps 2 # 向上 2 步(负数向下) +// go run ./cmd/migrate version # 查看当前版本与脏库状态 +// go run ./cmd/migrate force 3 # 修复脏库:强制设置版本号 +package main + +import ( + "fmt" + "os" + "strconv" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/migration" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting" +) + +const usage = `用法: + go run ./cmd/migrate up 执行到最新版本 + go run ./cmd/migrate down 全量回滚所有迁移 + go run ./cmd/migrate steps N 向上 N 步(N 为负数时向下) + go run ./cmd/migrate version 查看当前版本与脏库状态 + go run ./cmd/migrate force V 强制设置版本号并清除脏标记` + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "migrate failed: %v\n", err) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) == 0 { + fmt.Println(usage) + return nil + } + + // 仅读取 Mysql 配置段,不复用 main.go 的 setupSetting(后者会强制校验 JWT 等无关配置)。 + mysqlSetting, err := loadMysqlSetting() + if err != nil { + return err + } + + switch args[0] { + case "up": + if err := migration.Up(mysqlSetting); err != nil { + return err + } + fmt.Println("migrate up: done") + return nil + + case "down": + if err := migration.Down(mysqlSetting); err != nil { + return err + } + fmt.Println("migrate down: done") + return nil + + case "steps": + if len(args) < 2 { + return fmt.Errorf("steps 缺少步数参数\n%s", usage) + } + n, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("steps 参数必须为整数: %w", err) + } + if err := migration.Steps(mysqlSetting, n); err != nil { + return err + } + fmt.Printf("migrate steps %d: done\n", n) + return nil + + case "version": + version, dirty, err := migration.Version(mysqlSetting) + if err != nil { + return err + } + fmt.Printf("version: %d, dirty: %t\n", version, dirty) + return nil + + case "force": + if len(args) < 2 { + return fmt.Errorf("force 缺少版本号参数\n%s", usage) + } + v, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("force 参数必须为整数: %w", err) + } + if err := migration.Force(mysqlSetting, v); err != nil { + return err + } + fmt.Printf("migrate force %d: done\n", v) + return nil + + default: + return fmt.Errorf("unknown command: %s\n%s", args[0], usage) + } +} + +// loadMysqlSetting 读取 configs/config.yaml 的 Mysql 配置段。 +func loadMysqlSetting() (*setting.MysqlSettingS, error) { + s, err := setting.NewSetting() + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + var mysqlSetting setting.MysqlSettingS + if err := s.ReadSection("Mysql", &mysqlSetting); err != nil { + return nil, fmt.Errorf("read Mysql section: %w", err) + } + if mysqlSetting.DBName == "" { + return nil, fmt.Errorf("Mysql.DBName is empty in configs/config.yaml") + } + return &mysqlSetting, nil +} diff --git a/configs/config.example.yaml b/configs/config.example.yaml new file mode 100644 index 0000000..2f201b3 --- /dev/null +++ b/configs/config.example.yaml @@ -0,0 +1,125 @@ +Server: + Domain: test.com #服务域名,用于 CORS 允许来源(http/https 均放行);置 * 或不填则允许所有来源 + RunMode: release #gin运行模式,debug 和 release + HttpPort: 80 + ReadTimeout: 60 + WriteTimeout: 60 +Log: + LogSavePath: storage/logs + LogFileName: app + LogFileExt: .log + MaxSize: 100 + MaxBackups: 10 + MaxAge: 30 + LogLevel: Info #日志级别,Debug, Info, Warn, Error +Mysql: + Username: your_mysql_user + Password: your_mysql_password + Host: 127.0.0.1 + Port: 3306 + DBName: your_db_name + MaxIdleConns: 30 + MaxOpenConns: 100 + ConnMaxLifeTime: 3600 + ConnMaxIdleTime: 600 +Redis: + Host: 127.0.0.1 + Port: 6379 + Password: "" + DB: 0 + PoolSize: 20 + MinIdleConns: 5 +OTel: + Enabled: true + ServiceName: your_svc_name + ServiceVersion: 1.0.0 + Environment: dev + ExporterEndpoint: 127.0.0.1:4317 # OTLP gRPC 端点 + Insecure: true # 本地/内网不开 TLS + SamplerRatio: 1.0 # 采样率 0~1 +JWT: + SecretKey: replace_with_a_strong_random_secret + Issuer: yourIssuer + AccessTokenExpire: 7200 + RefreshTokenExpire: 86400 +Cos: + CosSecretID: your_cos_secret_id # 腾讯云 COS SecretId + CosSecretKey: your_cos_secret_key # 腾讯云 COS SecretKey + CosUploadDomain: your_cos_upload_domain # 上传域名 + CosDownloadDomain: your_cos_download_domain # 下载域名 + CdnDownloadSecretKey: your_cdn_download_secret_key # CDN 下载鉴权密钥 +Upload: + TmpSavePath: storage/uploads + MaxSizeMB: 20 + AllowedExtensions: + - jpg + - jpeg + - png + - gif + - webp + - pdf + - txt + - doc + - docx + - xls + - xlsx + - mp4 + +Tokenhub: + APIKey: your_tokenhub_api_key # 大模型网关 APIKey + Model: deepseek-v4-flash + +MattingModel: + Host: 127.0.0.1 + Port: 8001 + MaxRetry: 3 + +ClassificationModel: + Host: 127.0.0.1 + Port: 8000 + MaxRetry: 3 + +WechatMini: + AppID: your_mini_appid + AppSecret: your_mini_appsecret +WechatPay: + Enable: false # 是否启用微信支付;未开通时置 false,服务启动不加载支付客户端 + AppID: your_mini_appid # 小程序 AppID + MchID: your_mch_id # 商户号 + APIv3Key: your_apiv3_key_32bytes_len # APIv3 密钥(必须 32 字节) + SerialNo: your_merchant_cert_serial_no # 商户证书序列号 + PrivateKeyPath: configs/apiclient_key.pem # 商户 API 私钥 PEM 路径 + NotifyURL: https://your.domain/api/v1/order/pay/notify + RefundNotifyURL: https://your.domain/api/v1/order/refund/notify + +WechatVirtualPay: + # 微信小程序虚拟支付(XPay),与普通微信支付为独立商户号/独立结算。 + # 配置项在 MP 后台「虚拟支付 → 基本配置」获取;未开通时 Enable 置 false。 + # 环境自动选择:只填 SandboxAppKey 走沙箱测试(不真实扣款); + # 只填 EnvAppKey 或两把都填走现网(现网优先);两把都空则虚拟支付不可用。 + Enable: false + OfferID: your_virtual_pay_offer_id # 虚拟支付 OfferID(AppID) + EnvAppKey: your_virtual_pay_env_app_key # 现网 AppKey(env=0) + SandboxAppKey: your_virtual_pay_sandbox_app_key # 沙箱 AppKey(只填这一把即走沙箱;iOS 不支持沙箱) + Token: your_virtual_pay_notify_token # 消息推送验签 Token(配置回调地址时自定义) + +UserDefaultQuota: + # 新用户免费额度与注册赠送的会员天数;可显式配置为 0(额度类表示不赠送,GiftVIPDay 表示不赠送会员), + # 删除某项或整段则回落内置默认(额度 20/3/10/5,GiftVIPDay 3 天);负数视为非法配置,同样回落内置默认。 + # 额度语义:Cos 为永久额度(不每天重置);AI / OutfitShare / ClosetShare 为每日额度(每天 0 点重置)。 + Cos: 20 # 免费上传额度(永久) + AI: 3 # 免费 AI 推荐额度(每日) + OutfitShare: 10 # 免费社区分享额度(每日) + ClosetShare: 5 # 免费衣柜分享额度(每日,配置 0 表示不赠送) + GiftVIPDay: 3 # 注册赠送的体验会员天数(vip_type=4);0 表示不赠送; + # 删除该项或整段则回落内置默认 3 天;负数视为非法配置,同样回落内置默认。 + +AdReward: + # 广告激励额度:是否开启(关闭时 /ad-reward/grant 返回「广告激励未开启」)。 + # 发放金额由服务端固定配置,前端仅传 reward_type 与 ref_id(广告记录ID),不再传金额,杜绝刷额度; + # 各类型金额可删除或置 0(表示不发放该类型激励)。 + Enable: false + Cos: 1 # 广告激励赠送的 Cos 上传额度(永久) + AI: 1 # 广告激励赠送的 AI 推荐额度(每日) + OutfitShare: 1 # 广告激励赠送的社区分享额度(每日) + ClosetShare: 1 # 广告激励赠送的衣柜分享额度(每日) diff --git a/design/zt-closet-er.drawio b/design/zt-closet-er.drawio new file mode 100644 index 0000000..cd3aa96 --- /dev/null +++ b/design/zt-closet-er.drawio @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/代码审查报告-三次check.md b/docs/代码审查报告-三次check.md new file mode 100644 index 0000000..88bb6e0 --- /dev/null +++ b/docs/代码审查报告-三次check.md @@ -0,0 +1,178 @@ +# zt-closet-backend 全量代码三次审查报告 + +- **审查日期**:2026-09-09 +- **审查范围**:全量 Go 代码(`internal/`、`pkg/`、`global/`、`main.go`、`cmd/`,不含 `thirdparty/` Python 服务) +- **审查方式**:只读审查,未修改任何业务代码 +- **分级标准**: + - 严重:资损、安全漏洞、并发导致数据错乱/重复发放、可被直接利用的越权 + - 中等:边界条件缺失、一致性降级风险、错误处理不当导致的业务卡死 + - 轻微:代码风格、冗余、文档偏差、低概率窗口、死代码 + +> 说明:本报告为「三次 check」,在复核上一轮(二次 check)各项修复状态的基础上,继续深挖新增问题。上一轮已修复项统一在「第四部分」复核确认;上轮遗留未修复项在「第五部分」列明现状。 + +--- + +## 修复状态更新(2026-09-09) + +> 本报告原为只读审查。经用户确认后,已对以下「中等及以上」问题实施修复(按约定排除:M11 公开接口限流、M15 虚拟订单垃圾堆积): + +| 编号 | 问题 | 修复状态 | +| --- | --- | --- | +| S2-new | 广告激励刷额度 | ✅ 已修复:新增 `AdReward` 配置段(`Enable` 开关 + 各类型固定金额),DTO 移除 `Amount`,前端不再传金额,服务端定价 | +| S1-new | XPay 退款推送无反查 | ✅ 已修复:新增 `verifyXPayRefund` 反查微信侧退款真实性(状态+金额),fail-closed 触发重试 | +| M1-new | 自动退款卡死无重试 | ✅ 已修复:`autoRefundCancelledOrder` 改为生成待审核退款单(不再自动调微信退款,退款全部由管理员操作) | +| M2-new | 会员类型覆盖降级 | ✅ 已修复:`VIPGrantIdempotent` 按最终到期时长推导 `vip_type`(体验会员除外) | +| M3 | iOS 退款问询无条件建议退款 | ✅ 已修复:按权益消耗情况细化,已消耗则返回 `result_code=1` 拒绝 | +| M4 | platform 可伪造 | ✅ 已处理:明确 platform 仅作体验优化、真正资损防线为发货反查;dto 已含 `oneof` 白名单校验 | +| M9 | 并发 Paid 分支 pay_time 无兜底 | ✅ 已修复:抽 `resolvePaidTime` 统一兜底,`finalizeVirtualPaidOrder` 与并发 Paid 分支复用 | +| M12 | 上传无请求体大小限制 | ✅ 已修复:handler 入口加 `http.MaxBytesReader` 限制请求体 | + +> 备注:S1-new 反查依赖的微信 XPay `query_order` 退款字段(`refund_status`/`refund_fee`)与退款状态枚举为「待确认」,联调时需以官方文档校正。验证:`go build ./...` 与 `go vet ./...` 均通过。 + +--- + +## 一、结论摘要 + +- **上轮 2 个严重问题(S1 发货伪造、S2 道具价格同步)均已妥善处置**,发货链路的 `verifyXPayOrderPaid` 反查已落地,但**其对称缺口——XPay 退款推送无反查——本次新发现,构成新的资损/DoS 面(S1-new)**。 +- **本次新发现 1 个严重资损漏洞**:广告激励额度接口信任前端上报的金额与凭证 ID,可被任意用户无限刷额度(S2-new)。 +- 支付/退款/权益发放的核心事务、幂等、金额校验整体设计正确,上一轮确认良好的项继续成立。 +- 上轮遗留的中等问题(M3/M4/M9/M11/M12/M15)与大部分轻微问题(L 系列)**仍未处理**。 + +--- + +## 二、严重问题(新增) + +### S1-new. XPay 退款推送无微信侧真实性反查,可伪造退款回收权益(资损/DoS) + +- **位置**:`internal/service/virtualpay.go:295-326`(`handleRefundNotify`) +- **问题**:上一轮 S1 修复时,发货推送 `handleGoodsDeliver` 已新增 `verifyXPayOrderPaid`(`virtualpay.go:168-199`)——在本地校验后、条件流转前调用 `wechat.QueryOrder` 反查微信侧订单「已支付 + 金额一致 + 道具一致」,fail-closed。但**退款推送 `handleRefundNotify` 只做了 `RefundStatus == "SUCCESS"` 的白名单判断,没有对应的微信侧退款真实性反查**。 +- **攻击路径**:XPay 消息推送 POST 报文不携带签名(`ParseNotify` 只解析 JSON),攻击者可向公开路由 `POST /api/v1/xpay/notify` 构造 `Event=xpay_refund_notify`、`RefundStatus="SUCCESS"`、`OutTradeNo=<目标订单号>` 的报文: + 1. `handleRefundNotify` 白名单判断通过; + 2. 查订单(`OutTradeNo`)命中且非 Refunded; + 3. `settleRefundSuccess` 条件流转 `Paid -> Refunded` 成功 → **回收该订单已发放的会员天数与额度凭证** + 发「退款成功」通知。 +- **影响**: + - 对他人订单:攻击者若获取到他人订单号(`OD` + 时间戳 + 16 位 hex,存在展示/日志泄露渠道),可恶意回收他人权益、把他人订单标记为已退款,造成「用户付了钱、权益被回收、钱并未退回」的资损与客诉。 + - 对自己订单:结合 M3(iOS 退款问询无条件建议退款)存在权益白嫖放大。 +- **对比**:普通微信支付退款回调 `RefundNotify` 走官方 SDK 验签 + AES 解密(`pkg/wechat/pay.go:306-335`),不存在此问题;XPay 退款链路是发货反查落地后唯一仍「裸奔」的入口。 +- **修复建议**:`handleRefundNotify` 增加微信侧退款状态反查(`wechat` 层补一个退款状态查询接口,或复用 `QueryOrder` 的退款字段),确认「微信侧确实已退款 + 退款金额一致」后才 `settleRefundSuccess`;未通过返回失败应答(`ErrCode:1`)触发微信重试,并落库/告警。至少在反查落地前,对该路由加网关级来源防护。 + +### S2-new. 广告激励额度接口信任前端金额与凭证 ID,可无限刷额度(资损) + +- **位置**:`internal/service/adreward.go:24-54`(`Grant`) +- **问题**:`/ad-reward/grant`(需鉴权)接收前端上报的 `RewardType`、`Amount`、`RefID`,其中 **`Amount`(奖励额度数量)与 `RefID`(幂等凭证 ID)完全由客户端指定**,服务端: + 1. 不校验 `Amount` 上限(可传任意大值,如 999999); + 2. 不校验 `RefID` 的真实性(未与广告平台服务端回调或观看记录对账); + 3. 直接以 `Amount` 作为凭证数量 `QuotaGrantCreate` 落库(`ExpireTime=当天23:59:59`)。 +- **影响**:任意登录用户可反复调用,给自己发放任意数量的 Cos/AI/分享额度凭证(当天有效),彻底击穿额度体系,构成直接资损。 +- **修复建议**: + 1. 奖励额度改为**服务端按 RewardType 配置固定值**,不从客户端接收 `Amount`; + 2. `RefID` 必须由广告平台**服务端回调**下发并校验(或引入一次性凭证/签名),杜绝前端自造; + 3. 增加每日领取次数上限。 + +--- + +## 三、中等问题(新增) + +### M1-new. 自动退款失败后状态卡死,无重试机制(用户付了钱收不到退款) + +- **位置**:`internal/service/order.go:402-437`(`autoRefundCancelledOrder`) +- **问题**:订单「已取消但用户已付款」时自动发起退款: + 1. 先建退款流水 `Status=RefundStatusProcessing`(`order.go:411-420`); + 2. 调 `wechat.Refund`(`order.go:429`),**若失败仅记录日志后直接 return,未把流水置为 `RefundStatusFailed`**(`order.go:430-433`)。 + 3. 下次再次进入该函数时,幂等检查 `existing.Status != RefundStatusFailed` 命中(`order.go:404-408`),直接 return,跳过退款。 +- **影响**:`PayNotify` 对 Cancelled 分支返回 `nil`(成功应答,`order.go:197-198`),微信不会重推;后台无退款重试任务;用户也无法通过 `RefundApply` 再次申请(订单已 Cancelled,`RefundApply` 仅接受 `Paid`,`order.go:279-281`)。最终该订单退款**永久卡在 Processing**,用户付了钱拿不到退款。 +- **修复建议**:`wechat.Refund` 失败时把流水置为 `RefundStatusFailed`(复用已有失败重试语义);或增加独立的退款重试后台任务。 + +### M2-new. 会员续费时 `vip_type` 直接覆盖,年度会员续月包被降级为月度 + +- **位置**:`internal/service/order.go:618-655`(`grantVIP`)+ `internal/mysqldao/user.go:51-73`(`VIPGrantIdempotent`) +- **问题**:`grantVIP` 的 `newVIPType` 仅由本次购买 `memberDays >= 365` 决定(`order.go:626-629`),`VIPGrantIdempotent` 用 `vip_type = ?`(本次类型)直接 `SET`(`user.go:58`),**未根据最终 `vip_expire` 剩余时长推导**。场景: + - 年度会员(剩余 350 天)续费 30 天月包 → `vip_expire` 变为 380 天(> 365,应为年度),但 `vip_type` 被覆盖为「月度(2)」,字段自相矛盾,前端展示「月度会员」造成「被降级」客诉。 + - 反向:月会员续大额包也有类型跳变。 +- **修复建议**:`vip_type` 依据「续费后的 `vip_expire - now` 是否 ≥ 365 天」推导写入(或改为查询时统一按到期时长归一,DB 中 `vip_type` 仅作冗余),与 `buildUserInfoResponse` 的归一逻辑对齐。 + +--- + +## 四、上一轮修复项复核结论(已确认落地,无需再改) + +| 编号 | 上一轮问题 | 复核结论 | +| --- | --- | --- | +| S1 | XPay 发货推送伪造资损 | ✅ 已修复:`verifyXPayOrderPaid` 反查微信侧订单(金额/道具/支付态),fail-closed | +| S2 | 道具价格同步仅启动一次 | ✅ 已处置:服务端道具自动同步逻辑已删除,改由管理后台负责 | +| M1 | 退款推送 `RefundStatus` 空串误判 | ✅ 已修复:白名单 `XPayRefundStatusSuccess` 判断 | +| M2 | 发货校验失败静默吞掉 | ✅ 已修复:失败分支返回 `70009`,应答 `ErrCode:1` 触发重试 | +| M5 | 补偿重试上限被回调重置绕过 | ✅ 已修复:`BenefitGrantTaskReset` 保留 `retry_count` + 5 分钟冷却期 | +| M10 | `RefreshToken` 与文档「可选」矛盾 | ✅ 已修复:代码与文档统一为必填 | +| M13 | `Outfit.Public` 扣额失败回退竞态 | ✅ 已修复:`KeyLockOutfitPublic` per-outfit 锁串行化 | +| M14 | 衣物失败/删除并发漏退额度 | ✅ 已修复:`markProcessFailed` 重写为行锁单事务,与删除事务行锁互斥 | + +此外,上轮确认良好的核心链路本次继续成立:支付/退款回调金额校验、条件状态流转 + Redis 幂等锁双层防护、过期/取消竞态兜底、权益发放幂等(`(order_no,benefit_type)` 唯一索引 + claim 状态机 + 补偿先查流水)、退款回收「已用不回退」(`GREATEST` 保护)、SQL 全部参数绑定(无 SQLi)、响应 JSON 无 XSS、出站请求仅微信固定域名与配置的 AI 地址(无 SSRF)、密钥仅存配置文件(无硬编码)。 + +--- + +## 五、上一轮遗留未修复项(现状复核) + +### 中等(仍未处理) + +| 编号 | 问题 | 位置(现状) | +| --- | --- | --- | +| M3 | iOS 退款问询无条件建议退款,白嫖权益窗口 | `internal/service/virtualpay.go:332-342`(仍恒返回 `result_code=0`,注释声明为权衡) | +| M4 | 前端上报 `platform` 可伪造,绕过 iOS 最低 1 元限制 | `internal/service/virtualpay.go:47-50` + `internal/dto/order.go`(`Platform` 由前端上报) | +| M9 | 并发 Paid 分支 `pay_time` 无兜底 | `internal/service/virtualpay.go:267-274`(`finalizeVirtualPaidOrder` 已兜底,但并发 Paid 分支仍直接写 `deliver.WeChatPayInfo.PaidTime`) | +| M11 | 公开接口(wxlogin/refresh/pay/notify/xpay/notify)无限流 | `internal/routers/router.go:47-61`(无 rate limit 中间件) | +| M12 | 上传接口大小校验发生在 multipart 解析之后 | `internal/service/clothes.go:51-57`(`c.FormFile` 解析后才 `FileCheckMaxSize`) | +| M15 | 虚拟支付订单禁取消带来的垃圾订单堆积 | `internal/service/order.go:213-217`(设计权衡,建议加监控) | + +> M6/M7/M8(道具上传/发布轮询、分组重复发布、道具 ID 字符集)已随 S2 处置(道具同步逻辑删除)自然消除。 + +### 轻微(L 系列,仍未处理) + +| 编号 | 问题 | 位置 | +| --- | --- | --- | +| L1 | 通知 `read_time` 永不写入 | `internal/service/notification.go:69-104` | +| L2 | 并发点赞基于前置读 `wasLiked` 判断,可能重复发通知 | `internal/service/like.go:61-64` | +| L3 | 收藏列表已删穿搭被跳过导致 `total` 与 `list` 不一致 + 每页 N+1 查询 | `internal/service/collect.go:91-110` | +| L4 | CORS 未配置 Domain 时回退 `Allow-Origin: *` | `internal/middleware/cors.go` | +| L5 | `http.Server` 未设 `IdleTimeout`/`ReadHeaderTimeout`(slowloris 风险) | `main.go:70-78` | +| L6 | 临时封禁无自动解封逻辑 | `internal/service/user.go:95`(`Status != Normal` 直接拒绝,不按 `ban_expire` 判定) | +| L7 | 封禁状态受用户缓存延迟影响 | `internal/middleware/lzcwyjwt.go:78-99`(缓存命中直接用缓存 status) | +| L8 | `RefreshToken` 先删旧会话再写新会话,Del 成功 Set 失败时新旧 token 全失效 | `internal/service/user.go:250-253` | +| L9 | `OrderExpireByTime` 硬编码状态字面量 1/4 | `internal/mysqldao/order.go` | +| L10 | 迁移 `down` 全量回滚无二次确认 | `pkg/migration/migration.go` | +| L12 | XPay `getAccessToken` 无重试 | `pkg/wechat/virtualpay.go:180-229` | +| L13 | `Outfit.Create` 主表成功后明细失败留下孤儿穿搭 | `internal/service/outfit.go:62-68`(与 AI 推荐不一致,AI 推荐会删主表) | +| L14 | `Outfit.Update` 基础字段与明细重建分两个事务 | `internal/service/outfit.go:121-140` | +| L15 | `ClosetShare.Create` 明细失败仅回退额度,主记录残留空分享 | `internal/service/closet.go:92-95` | +| L16 | 信号量全局共享、阻塞式获取不可取消 | `internal/service/clothes.go:26,106` | +| L17 | 扣额建记录成功、goroutine 启动前崩溃,衣物停留「排队中」无恢复 | `internal/service/clothes.go:88-95` | +| L18 | COS 签名 URL 无过期参数 | `pkg/upload/upload.go` | +| L19 | AI 推荐 Redis 锁 180s 无续期 | `internal/service/airecommend.go:50` | +| L20 | 运势生成单次失败无重试 | `pkg/tokenhub/scheduler.go` | +| L21 | `time.Sleep` 在系统时间回拨时可能忙循环 | `pkg/tokenhub/scheduler.go` | +| L22 | 锁 key 手写字符串与 redismodel 常量两处维护 | `internal/service/benefit_compensation.go:40` | +| L23 | 已废弃 DAO(`QuotaIncreaseUsed`/`QuotaDecreaseUsed`/`QuotaConsumeWithFlow`/`QuotaRefundWithFlow`/`QuotaGrantIdempotent`/`QuotaRevokeIdempotent`)无调用点,死代码 | `internal/mysqldao/quota.go`(本次已确认全工程无调用) | +| L24 | 普通支付 `finalizePaidOrder` 的 `pay_time` 用本地时间而非微信 `SuccessTime` | `internal/service/order.go:457-462` | +| L25 | 道具同步全量上架商品无通道开关 | 已随 S2 消除,可忽略 | + +### 新增轻微 + +| 编号 | 问题 | 位置 | +| --- | --- | --- | +| L26 | `recordDailyUsage` 首次并发创建当日计数行无 OnConflict 幂等,第二个请求 1062 冲突(仅日志噪音,不阻断) | `internal/service/service.go:85-117` | +| L27 | 登录 `AuthTokenSet` fail-open 与刷新 `AuthTokenSet` fail-closed 策略不一致 | `internal/service/user.go:107-110` vs `user.go:250-253` | +| L28 | 退款回收 `VIPRevokeIdempotent` 仅扣 `vip_expire`,不重算 `vip_type`,DB 字段可能残留过期类型 | `internal/mysqldao/user.go:77-101` | + +--- + +## 六、修复优先级建议 + +| 优先级 | 编号 | 一句话结论 | +| --- | --- | --- | +| P0(上线前) | S1-new | XPay 退款推送无微信侧反查,可伪造退款回收权益,必须补齐反查 | +| P0(上线前) | S2-new | 广告激励额度信任前端金额/凭证 ID,可无限刷额度,必须服务端定价 + 校验 | +| P1 | M1-new | 自动退款失败卡死无重试,用户付钱收不到退款 | +| P1 | M2-new | 会员续费 `vip_type` 覆盖导致年度会员降级 | +| P2 | M3/M4/M9/M11/M12 | iOS 白嫖、platform 伪造、pay_time 兜底、公开接口限流、上传防护 | +| P3 | L1-L28 | 按影响面择机处理;其中 L13/L14/L15 与一致性相关,建议优先 | + +> 备注:本报告仅列出问题与建议,未修改任何代码。S1-new 的修复需先联调确认 XPay `query_order` / 退款查询接口在官方文档中的字段与状态枚举(`wechat/virtualpay.go` 中多处已标注「以官方枚举为准,联调时校正」)。 diff --git a/docs/代码审查报告-二次check.md b/docs/代码审查报告-二次check.md new file mode 100644 index 0000000..e03658c --- /dev/null +++ b/docs/代码审查报告-二次check.md @@ -0,0 +1,207 @@ +# zt-closet-backend 全量代码二次审查报告 + +- **审查日期**:2026-09-08 +- **审查范围**:全量 Go 代码(约 9700 行核心业务代码),重点深挖支付链路(虚拟支付 XPay、微信支付 V3、订单状态机、权益发放/退款/补偿) +- **审查方式**:只读审查,未修改任何业务代码 +- **分级标准**: + - 严重:资损、安全漏洞、并发导致数据错乱/重复发放、状态机死锁 + - 中等:边界条件缺失、一致性降级风险、错误处理不当 + - 轻微:代码风格、冗余、文档偏差、低概率窗口 + +--- + +## 一、严重问题(必须修复) + +### S1. XPay 发货推送无验签与订单真实性反查,可伪造发货通知零成本获取权益(资损) —— ✅ 已修复(2026-09-08) + +> 修复内容:发货推送处理链路新增微信侧订单真实性反查 `verifyXPayOrderPaid`(`internal/service/virtualpay.go`),在本地校验通过后、条件流转前调用 `wechat.QueryOrder` 反查订单已支付、金额与道具一致才发放权益;fail-closed,未通过返回 `70009 订单真实性校验失败` 触发微信重试。配套 `IsOrderPaidStatus` 白名单判断与单测(`pkg/wechat/virtualpay.go`)。 + +- **位置**:`internal/routers/router.go:59`(公开路由)、`internal/routers/api/v1/order.go:163-171`、`internal/service/virtualpay.go:290-374` +- **问题**:微信虚拟支付的消息推送 POST 报文**不携带签名**(URL 配置时的 GET SHA1 验签仅用于验证 URL 归属,`XPayVerify` 只处理 GET)。`XPayNotify` 收到 POST 后直接信任报文内容,仅做本地校验(订单存在、pay_channel=2、product_id/amount/env 与订单快照一致)。这些校验信息对攻击者完全可控: + 1. 攻击者用合法账号创建虚拟支付订单(自己下单即可拿到 `order_no`、`product_id`、金额;`env` 只需在 0/1 间试一次); + 2. 向公开路由 `POST /api/v1/xpay/notify` 发送伪造的 `xpay_goods_deliver_notify` 报文; + 3. 所有本地校验通过 → 订单 Pending→Paid → 权益发放 → **未付款即到账**。 +- **对比**:普通微信支付回调 `PayNotify` 走官方 SDK 验签 + AES 解密,不存在此问题;XPay 链路是唯一裸奔的资损入口。 +- **修复建议**:发货处理前调用 `wechat.QueryOrder`(带 HMAC-SHA256 签名的服务端 API,签名无法伪造)反查微信侧真实订单状态,确认「已支付 + 金额一致 + 道具一致」后才流转发放;推送仅作为触发信号。退款推送同样需要反查 `QueryOrder` 的退款状态。至少在反查落地前,建议给该路由加网关级来源防护并告警。 + +### S2. 虚拟支付道具价格同步只在服务启动执行一次,运营改价后微信侧与本地价格不一致(资损/拒付风险) —— ✅ 已确认由管理后台负责,已删除服务端自动同步(2026-09-08) + +> 处置:删除服务端自动道具同步逻辑(`main.go` 调用、`StartVirtualGoodsSync`/`SyncVirtualGoods`/`syncOneGoods`/`pollGoodsTask`、wechat 层道具上传/发布函数、DAO `GoodsListVirtualSync`/`GoodsUpdateByID`)。道具上传/发布/改价由管理后台负责并回填 `goods.product_id`/`product_status`。README 已标注运营约束:改价后必须通过管理后台同步,否则微信侧价格与本地不一致会导致发货金额校验拒绝或差价资损。 + +- **位置**:`internal/service/virtualpay.go:32-41`(`StartVirtualGoodsSync` 仅启动时触发一次)、`main.go:68`、`syncOneGoods` 用 `goods.DiscountPrice` 作为道具价格 +- **问题**:运营在 DB 修改商品售价后,若服务未重启,微信侧道具价格仍是旧价。用户下单时按本地新价生成 `signData`(goodsPrice),但微信侧按道具旧价扣款 → 出现两种情况: + - 微信扣款 > 本地订单金额:发货推送 `ActualPrice != order.PayAmount` 校验失败,**拒绝发货**,用户付了钱拿不到权益(客诉/监管风险); + - 微信扣款 < 本地订单金额:少扣钱,资损。 +- **修复建议**:道具同步改为周期任务(如每 10 分钟增量同步),或提供管理端改价入口同步触发;对金额不匹配的发货推送增加人工兜底策略(按实际扣款金额改单发货 + 告警),而不是直接拒绝。 + +--- + +## 二、中等问题 + +### M1. 退款推送 `RefundStatus` 为空串时被误判为「退款成功」并回收权益 —— ✅ 已修复(2026-09-08) + +> 修复:新增 `XPayRefundStatusSuccess` 常量,`handleRefundNotify` 改为白名单判断,仅 `RefundStatus == "SUCCESS"` 才回退权益,空串/未知状态仅告警。 + +- **位置**:`internal/service/virtualpay.go:386-391` +- **问题**:判断条件 `if refund.RefundStatus != "" && refund.RefundStatus != "SUCCESS"`,当推送报文字段缺失或字段名与官方文档不符(解析结果为空串)时,跳过 warn 分支,直接走 `settleRefundSuccess` 回收权益并置订单 Refunded。若实际退款并未成功,会造成误回收。 +- **修复建议**:改为白名单判断——仅当 `RefundStatus == "SUCCESS"` 才回退;空串/未知状态仅记录日志并告警,等待后续推送。 + +### M2. 发货推送校验失败时返回成功应答,微信不重试且无告警,资损线索被静默吞掉 —— ✅ 已修复(2026-09-08) + +> 修复:`handleGoodsDeliver` 五个校验失败分支(订单不存在/渠道不符/道具不符/金额不符/环境不符)改返回 `70009`,应答 `ErrCode:1` 触发微信重试;锁竞争分支保持成功应答防重试风暴。 + +- **位置**:`internal/service/virtualpay.go:298-328`(order not found / product mismatch / amount mismatch / env mismatch 均 `return nil`),`XPayNotify` 对 `handleGoodsDeliver` 返回 nil 时回 `ErrCode:0` +- **问题**:金额不符、道具不符是资损/攻击线索,当前仅打 error 日志,微信收到成功应答后不再推送,无任何人工介入或监控渠道。回调原始报文(`raw`)也未持久化(未落库)。 +- **修复建议**:校验失败返回失败应答(`ErrCode:1`)让微信重试(可限制重试次数防风暴),并将校验失败的推送原始报文落库/告警到监控。 + +### M3. iOS 退款问询无条件「建议退款」,存在白嫖权益窗口 + +- **位置**:`internal/service/virtualpay.go:410-424`(`handleIOSRefundQuery` 恒返回 `result_code=0`) +- **问题**:用户在 iOS 购买会员/额度包后立即走 App Store 申请退款,后端无条件建议退款;退款成功后 `revokeOrderBenefits` 回收权益,但「已消耗部分不回退」(GREATEST 保护)且会员天数不回退到过去。对会员类权益,用户可在会员有效期内退款白嫖。代码注释已声明该权衡,但从业务角度属于明确的风险敞口。 +- **修复建议**:按权益使用情况细化拒绝策略(如已消耗额度超过阈值时 `result_code=1 + evidence`),或对会员类订单设置退款冷却期。 + +### M4. 前端上报的 `platform` 可伪造,绕过 iOS 最低 1 元限制 + +- **位置**:`internal/service/virtualpay.go:172`、`internal/dto/order.go`(`Platform` 由前端上报) +- **问题**:iOS 用户上报 `platform=android` 即可绕过 100 分最低金额校验。微信侧 iOS 调起低金额虚拟支付会失败,产生无法取消(虚拟订单禁取消)的垃圾订单,堆积至过期。 +- **修复建议**:该限制本身只是前端体验保护,不构成资损;可在发货时校验平台一致性,或对超低价商品在商品层面限制,订单滞留靠过期清理即可。 + +### M5. 补偿任务 Failed 状态被支付回调重置(retry_count 清零),重试上限被绕过 —— ✅ 已修复(2026-09-08) + +> 修复:`BenefitGrantTaskReset` 不再清零 `retry_count`;Failed 任务增加 5 分钟冷却期(`benefitRetryCooldownSeconds`),冷却期内回调仅告警不重置,恢复重试上限与人工介入机制。 + +- **位置**:`internal/service/order.go:518-525`(Failed 分支 `BenefitGrantTaskReset`)、`internal/mysqldao/benefit_grant_task.go:78-89`(Reset 将 `retry_count` 归零) +- **问题**:任务重试 3 次失败进入 Failed(等待人工介入),但微信回调重试会再次触发 `grantOrderBenefits` → 唯一索引冲突 → `existing.Status == Failed` → Reset(`retry_count=0`)→ 立即重新发放。若故障持续,任务在 Failed 与 Pending 之间无限循环,「重试超限告警人工介入」机制失效,且每次回调都会触发一次发放尝试。 +- **修复建议**:Reset 时保留 `retry_count`(不归零),或对 Failed 任务在回调路径仅记录告警、不自动重置,由人工确认后重置。 + +### M6. 道具上传/发布任务轮询仅 2.5 秒(5 次 × 500ms),大概率误判失败 + +- **位置**:`internal/service/virtualpay.go:25-27`、`119-141`(`pollGoodsTask`) +- **问题**:微信道具上传/发布任务耗时未在代码中确认(注释自述「联调时核对」),若实际耗时超过 2.5 秒,`syncOneGoods` 会把商品置为 `product_status=3 发布失败`,导致虚拟商品下单入口长期不可用,直到下次重启重新同步。 +- **修复建议**:增加轮询总时长(如 30 秒、退避间隔),或超时后保持「发布中」状态待下次同步周期重查。 + +### M7. 多商品循环中每个商品都重复发布整个分组 + +- **位置**:`internal/service/virtualpay.go:76-116`(`syncOneGoods` 对每个商品都执行 `StartUploadGoods` + `StartPublishGoods`,且发布参数只有 `group_id`) +- **问题**:若微信 API 语义为「发布分组内全部待发布道具」,循环 N 个商品会发布 N 次分组,冗余且可能触发频率限制;若语义为单道具,则参数不匹配。另外「对已发布道具重复 upload+publish 视为改价」这一假设需要官方文档确认(已发布道具是否允许改价)。 +- **修复建议**:联调确认 group 语义后重构:全部 upload 完成后统一 publish 一次;改价策略按官方支持方式实现。 + +### M8. 道具 ID 直接复用 `GoodsCode`,可能不符合微信 goods_id 字符集规范 + +- **位置**:`internal/service/virtualpay.go:77`(`productID := goods.GoodsCode`) +- **问题**:微信虚拟支付道具 ID 一般有字符集限制(字母数字、长度上限)。若商品编码含下划线等特殊字符,道具上传会失败,虚拟支付功能整体不可用。 +- **修复建议**:联调核对规范,必要时做编码映射(如取 MD5 前缀或规范化 ID)并保持 `goods.product_id` 回填一致。 + +### M9. 发货推送并发分支中 `pay_time` 无兜底(与 finalize 路径不一致) + +- **位置**:`internal/service/virtualpay.go:349-356`(OrderStatusPaid 分支直接写 `deliver.WeChatPayInfo.PaidTime`) +- **问题**:`finalizeVirtualPaidOrder` 对 `PaidTime <= 0` 有兜底(取 `nowUnix()`),但并发回调的 Paid 分支没有;推送字段缺失时 `pay_time` 会被写成 0,订单列表展示异常。 +- **修复建议**:抽公共函数统一兜底。 + +### M10. `RefreshToken` 要求旧 access_token 必填,与接口文档「可选」矛盾 —— ✅ 已修复(2026-09-08) + +> 修复:代码与接口文档均为必填,README 接口表中 access_token 由「否」改为「是」并说明缺失返回 10000,与实现一致。 + +- **位置**:`internal/service/user.go:240-242`、`接口文档.md`(access_token 标注「否」) +- **问题**:文档声明不传旧 access_token 也能刷新(向前兼容),但代码对空值直接返回参数错误,前端若按文档实现会刷新失败。 +- **修复建议**:二选一并保持一致——文档改为必填,或代码放宽为可选(不传则不吊销旧 access)。 + +### M11. 公开接口无任何限流防护 + +- **位置**:`internal/middleware/`(无 rate limit 中间件)、`internal/routers/router.go:45-60` +- **问题**:`/user/wxlogin`、`/user/refresh-token`、`/order/pay/notify`、`/xpay/notify` 均无频率限制。暴力调用 code2session 会消耗微信侧配额(触发 45011 后全员登录失败);回调接口可被刷量放大 S1 的伪造风险。 +- **修复建议**:接入限流中间件(按 IP + 按用户),回调类接口单独设置更严格的防护策略。 + +### M12. 上传接口无请求体大小限制(校验发生在 multipart 解析之后) + +- **位置**:`internal/service/clothes.go:51-57`、`main.go:73-81`(仅 `MaxHeaderBytes`) +- **问题**:`c.FormFile` 先完整解析 multipart(超出 `MaxMultipartMemory` 32MB 的部分落临时文件),之后才做 `FileCheckMaxSize` 校验。攻击者可发送超大 body 消耗内存与磁盘。 +- **修复建议**:在 handler 入口用 `http.MaxBytesReader` 按 `Upload.MaxSizeMB` 提前限制请求体。 + +### M13. `Outfit.Public` 扣额失败回退与并发幂等返回存在语义冲突 —— ✅ 已修复(2026-09-08) + +> 修复:新增 `KeyLockOutfitPublic`,公开/取消公开全流程加 per-outfit Redis 锁(10s)串行化,锁失败返回 10003;Redis 异常 fail-open。串行化后「A 回退 → B 条件更新」语义正确。 + +- **位置**:`internal/service/outfit.go:210-224` +- **问题**:请求 A 条件更新(私→公)成功、扣额度失败、回退公开态;期间并发请求 B 的条件更新返回 0 行(已被 A 置为公开),B 幂等返回「成功」。最终穿搭实际为未公开,但 B 用户收到成功响应,且双方均未写审核日志。 +- **修复建议**:扣额失败回退后返回明确错误码(而非让并发请求误报成功);或回退前重查状态,仅在仍为公开时回退。 + +### M14. 衣物「异步处理失败」与「用户删除」并发存在额度漏退窗口 —— ✅ 已修复(2026-09-08) + +> 修复:`markProcessFailed` 重写为行锁单事务(FOR UPDATE → 置失败 → 同事务回退额度 + 写流水),与删除事务通过行锁互斥,两路径恰好回退一次;删除 `refundIfExists` 与 DAO `ClothesMarkProcessFailedIfNotFailed`。 + +- **位置**:`internal/service/clothes.go:216-238`(`markProcessFailed` → `refundIfExists`)与 `306-368`(删除事务) +- **问题**:时序:异步路径先提交 `image_process_status=5` → 删除事务 `FOR UPDATE` 读到失败态跳过回退并软删 → 异步路径随后 `ClothesGetByUUID` 读不到(已软删)跳过回退。两个「应回退」的事件叠加,结果零回退,用户损失 1 点上传额度(概率低,但真实存在)。 +- **说明**:上一轮担心的「双退」窗口经复核已被防御(GORM soft_delete 自动过滤 + 条件更新 + 删除事务行锁串行化),不会重复回退;但漏退窗口未覆盖。 +- **修复建议**:删除事务中对「状态=失败但删除前未回退过」的场景仍执行回退(可查回退流水是否存在判断),或 `refundIfExists` 改为按流水幂等凭证条件回退。 + +### M15. 虚拟支付订单禁取消带来的垃圾订单堆积(设计权衡) + +- **位置**:`internal/service/order.go:213-217` +- **问题**:虚拟支付订单禁取消后,用户下单不支付只能等 15 分钟过期,过期订单仅靠每分钟扫描批量更新,量大时扫描压力与脏数据堆积。属可接受的设计权衡(避免「已付款但权益不发放」),建议加监控观察堆积量。 + +--- + +## 三、轻微问题 + +| # | 问题 | 位置 | 建议 | +|---|---|---|---| +| L1 | 通知 `read_time` 永不写入,恒为 0 | `internal/mysqldao/notification.go:53-60` | 标记已读时写入 `read_time` | +| L2 | 并发点赞时基于前置读的 `wasLiked` 判断,可能重复发通知 | `internal/service/like.go:61-65` | 通知本身加幂等凭证或改为「状态变更成功才通知」(upsert 返回是否新增) | +| L3 | 收藏列表:已删除穿搭被跳过导致 `total` 与 `list` 长度不一致,且每页 N+1 查询 | `internal/service/collect.go:91-110` | 批量查询 + 过滤后重算 total,或 join 查询 | +| L4 | CORS 未配置 Domain 时回退 `Allow-Origin: *` | `internal/middleware/cors.go:21` | 生产环境强制配置 Domain | +| L5 | `http.Server` 未设 `IdleTimeout`/`ReadHeaderTimeout`,存在 slowloris 风险 | `main.go:73-81` | 补充超时配置 | +| L6 | 临时封禁无自动解封逻辑,`ban_expire` 到期后账号永久禁用 | `internal/service/user.go:95` | 增加解封后台任务或查询时按 `ban_expire` 动态判定 | +| L7 | 封禁状态受 24h 用户缓存延迟影响,改库封禁最长 24h 生效 | `internal/middleware/lzcwyjwt.go:78-92` | 运营封禁入口需同步删除缓存 | +| L8 | `RefreshToken` 先删旧会话再写新会话,Del 成功 Set 失败时新旧 token 全部失效,用户被强制登出 | `internal/service/user.go:250-253` | Set 失败时保留旧会话或允许旧 refresh 重试 | +| L9 | `OrderExpireByTime` 硬编码状态字面量 1/4,与 service 层常量重复维护 | `internal/mysqldao/order.go:62-74` | 收敛为共享常量 | +| L10 | 迁移 `down` 为全量回滚且无二次确认,生产误执行即删全库 | `pkg/migration/migration.go` | 增加环境检查/交互确认 | +| L11 | XPay 商品同步启动失败无重试与告警渠道 | `internal/service/virtualpay.go:32-41` | 失败时上报监控 | +| L12 | XPay 客户端 HTTP 无重试,`getAccessToken` 网络抖动直接失败 | `pkg/wechat/virtualpay.go:184-233` | 关键调用加 1~2 次重试 | +| L13 | `Outfit.Create` 主表创建成功后明细失败,留下孤儿穿搭 | `internal/service/outfit.go:59-65` | 与 AI 推荐一致:失败时删除主记录 | +| L14 | `Outfit.Update` 基础字段与明细重建分两个事务,中途失败状态不一致 | `internal/service/outfit.go:118-140` | 合并事务或先明细后主表 | +| L15 | `ClosetShare.Create` 明细失败仅回退额度,主记录残留空分享 | `internal/service/closet.go:92-95` | 失败时删除主记录 | +| L16 | 衣物异步流水线信号量全局共享、阻塞式获取不可取消,单用户可占满 10 槽位 | `internal/service/clothes.go:26,106` | 获取信号量支持 ctx 取消,或 per-user 限额 | +| L17 | 扣额度 + 建记录成功后、goroutine 启动前崩溃,衣物永远停留「排队中」,无恢复任务 | `internal/service/clothes.go:88-95` | 增加启动/周期扫描重置超时排队记录 | +| L18 | COS 签名 URL 无过期参数,泄露后长期有效 | `pkg/upload/upload.go:165-178` | 签名加有效期 | +| L19 | AI 推荐 Redis 锁 180s 无续期,大模型响应逼近超时时可并发重复扣额 + 重复调用 | `internal/service/airecommend.go:50` | 锁续期或加大 TTL 并依赖额度流水幂等 | +| L20 | 运势生成任务单次执行失败无重试,当天数据可能不完整 | `pkg/tokenhub/scheduler.go:52-101` | 失败后延迟重试 | +| L21 | `time.Sleep(wait)` 在系统时间回拨时可能忙循环 | `pkg/tokenhub/scheduler.go:35-43` | wait<=0 时设最小间隔 | +| L22 | 锁 key 手写字符串(`ztcloset:lock:benefit:compensation` 等)与 redismodel 常量两处维护,易漂移 | `internal/service/benefit_compensation.go:40` | 收敛到 redismodel | +| L23 | 已废弃 DAO(`QuotaIncreaseUsed`/`QuotaDecreaseUsed` 等)与死代码未清理 | `internal/mysqldao/quota.go:36-64` | 删除防误用 | +| L24 | 普通支付回调 `finalizePaidOrder` 的 `pay_time` 用本地时间而非微信 `SuccessTime` | `internal/service/order.go:457-462` | 优先使用回调时间字段 | +| L25 | 道具同步任务将「全部上架商品」同步为虚拟道具,无商品级开关区分普通/虚拟通道 | `internal/mysqldao/goods.go:62-75` | 视业务需要增加商品级通道开关 | + +--- + +## 四、上一轮修复项复核结论(已确认良好) + +以下为上一轮「修复支付业务逻辑问题」计划涉及的修复点,本轮复核确认实现正确,无需再改: + +1. **支付回调金额校验**:`PayNotify`/`RefundNotify`/XPay 发货推送均校验回调金额与订单/流水一致后才流转。✓ +2. **条件状态流转防重复回调**:`OrderUpdateStatusByOrderNo`/`RefundFlowLogUpdateStatusByFrom` 条件更新 + Redis 幂等锁(30s)双层防护。✓ +3. **过期/取消竞态兜底**:普通支付「已过期→补发货」「已取消→自动退款」,XPay「已过期/已取消→补发货」分支均已实现且使用重新查询的最新订单对象。✓ +4. **权益发放幂等**:`benefit_grant_tasks` 唯一索引 (order_no, benefit_type) + claim 状态机 + 补偿任务先查流水幂等,链路完整。(M5 的重置绕过是唯一遗留缺口) +5. **退款回收「已用不回退」**:`GREATEST(used, total-n)` 与 `GREATEST(now, vip_expire-days)` 实现正确。✓ +6. **虚拟支付订单禁取消、iOS 订单禁主动退款**:位置与校验顺序正确(归属校验 → 渠道校验 → 状态校验)。✓ +7. **事务边界**:额度扣减/回退/发放/回收、VIP 发放/回收均为「数值变更 + 回读 + 写流水」同事务,唯一索引兜底幂等。✓ +8. **SQL 注入面**:全部 DAO 使用参数绑定,未发现 SQLi。✓ +9. **XSS**:响应均为 JSON 结构化输出,Gin 默认无模板渲染场景,未发现 XSS。✓ +10. **SSRF**:出站请求仅微信固定域名与配置的 AI 服务地址,无用户可控 URL。✓ +11. **密钥管理**:JWT Secret 启动强制校验、APIv3Key 32 字节校验、所有密钥仅存配置文件(已 gitignore),未发现硬编码。✓ + +--- + +## 五、修复优先级建议 + +| 优先级 | 编号 | 一句话结论 | +|---|---|---| +| P0(上线前) | S1 | XPay 推送伪造发货资损漏洞,必须加订单反查 | +| P0(上线前) | S2 | 道具价格同步仅启动一次,改价即资损 | +| P1 | M1、M2 | 退款误回收、校验失败静默(资损线索) | +| P1 | M5 | 补偿重试上限被绕过 | +| P2 | M3、M4、M6-M9 | iOS 白嫖、道具同步可靠性 | +| P2 | M10-M14 | 文档矛盾、限流、上传防护、公开竞态、衣物漏退 | +| P3 | L1-L25 | 按影响面择机处理 | + +> 备注:本报告仅列出问题与建议,未修改任何代码。S1/S2 的修复方案(订单反查、同步周期化)涉及与微信 XPay 官方文档的联调核对,建议修复前先确认 `query_order` 等接口在官方文档中的字段与状态枚举。 diff --git a/global/cos.go b/global/cos.go new file mode 100644 index 0000000..1293f6a --- /dev/null +++ b/global/cos.go @@ -0,0 +1,7 @@ +package global + +import "github.com/tencentyun/cos-go-sdk-v5" + +var ( + CosClient *cos.Client +) diff --git a/global/mysql.go b/global/mysql.go new file mode 100644 index 0000000..34f5ee4 --- /dev/null +++ b/global/mysql.go @@ -0,0 +1,5 @@ +package global + +import "gorm.io/gorm" + +var MysqlEngine *gorm.DB diff --git a/global/otel.go b/global/otel.go new file mode 100644 index 0000000..1995ccd --- /dev/null +++ b/global/otel.go @@ -0,0 +1,17 @@ +package global + +import ( + "context" + + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" +) + +var ( + // Tracer 全局 Tracer,业务层通过 Tracer.Start 创建 span。 + Tracer trace.Tracer + // Meter 全局 Meter,供自定义指标使用。 + Meter metric.Meter + // OTelShutdown 聚合的 OTel 优雅关闭函数,进程退出时调用以 flush 缓冲数据。 + OTelShutdown func(context.Context) error +) diff --git a/global/redis.go b/global/redis.go new file mode 100644 index 0000000..b52f530 --- /dev/null +++ b/global/redis.go @@ -0,0 +1,5 @@ +package global + +import "github.com/redis/go-redis/v9" + +var RedisEngine *redis.Client diff --git a/global/setting.go b/global/setting.go new file mode 100644 index 0000000..10b7c85 --- /dev/null +++ b/global/setting.go @@ -0,0 +1,22 @@ +package global + +import "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting" + +var ( + ServerSetting *setting.ServerSettingS + LogSetting *setting.LogSettingS + MysqlSetting *setting.MysqlSettingS + RedisSetting *setting.RedisSettingS + OtelSetting *setting.OtelSettingS + JWTSetting *setting.JWTSettingS + UploadSetting *setting.UploadSettingS + CosSetting *setting.CosSettingS + TokenhubSetting *setting.TokenhubSettingS + MattingModelSetting *setting.MattingModelSettingS + ClassificationModelSetting *setting.ClassificationModelSettingS + WechatMiniSetting *setting.WechatMiniSettingS + WechatPaySetting *setting.WechatPaySettingS + VirtualPaySetting *setting.VirtualPaySettingS + UserDefaultQuotaSetting *setting.UserDefaultQuotaSettingS + AdRewardSetting *setting.AdRewardSettingS +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cff22f4 --- /dev/null +++ b/go.mod @@ -0,0 +1,91 @@ +module git.lzcwy.cn/zhengchiliu/zt-closet-backend + +go 1.27.0 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/go-sql-driver/mysql v1.10.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 + github.com/redis/go-redis/v9 v9.22.0 + github.com/spf13/viper v1.21.0 + github.com/tencentyun/cos-go-sdk-v5 v0.7.75 + github.com/wechatpay-apiv3/wechatpay-go v0.2.21 + go.opentelemetry.io/contrib/bridges/otelslog v0.20.1 + go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.71.0 + go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0 + go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 + go.opentelemetry.io/otel/log v0.22.0 + go.opentelemetry.io/otel/metric v1.46.0 + go.opentelemetry.io/otel/sdk v1.46.0 + go.opentelemetry.io/otel/sdk/log v0.22.0 + go.opentelemetry.io/otel/sdk/metric v1.46.0 + go.opentelemetry.io/otel/trace v1.46.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.2 + gorm.io/plugin/soft_delete v1.2.1 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.3 // indirect + github.com/bytedance/sonic/loader v0.5.2 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clbanning/mxj v1.8.4 // indirect + github.com/cloudwego/base64x v0.1.7 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/go-querystring v1.2.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/leodido/go-urn v1.5.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mozillazg/go-httpheader v0.4.0 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.61.0 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.2 // indirect + go.mongodb.org/mongo-driver/v2 v2.8.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/arch v0.30.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/grpc v1.83.1 // indirect + google.golang.org/protobuf v1.36.12 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0c4b6c5 --- /dev/null +++ b/go.sum @@ -0,0 +1,284 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw= +github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY= +github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc= +github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo= +github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= +github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= +github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= +github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60= +github.com/mozillazg/go-httpheader v0.4.0 h1:aBn6aRXtFzyDLZ4VIRLsZbbJloagQfMnCiYgOq6hK4w= +github.com/mozillazg/go-httpheader v0.4.0/go.mod h1:PuT8h0pw6efvp8ZeUec1Rs7dwjK08bt6gKSReGMqtdA= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= +github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= +github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0= +github.com/tencentyun/cos-go-sdk-v5 v0.7.75 h1:eRCGP5chujSYGFnsCPxlhQcN9wtlSO/4eXrxKtLVAIw= +github.com/tencentyun/cos-go-sdk-v5 v0.7.75/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg= +github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc= +github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs= +github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.mongodb.org/mongo-driver/v2 v2.8.2 h1:b6o2m7zL8g2URuO8urBedAylxojybKXNZTxgkOcl+2w= +go.mongodb.org/mongo-driver/v2 v2.8.2/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/otelslog v0.20.1 h1:5sHc4ToTFjfSZCtGAAM6jPunICAmJX73htv372T4ipc= +go.opentelemetry.io/contrib/bridges/otelslog v0.20.1/go.mod h1:oa6kgvyz/3GYW04dohd0++xJIH4xdQY8PAbpeCMaM8M= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.71.0 h1:TMTU0sQyqsF1QU+/Q4LAZlLOx1L3FJDbk5N2RVB1nx4= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.71.0/go.mod h1:QzTELfxkj/tFEZSD22OPPwLet5nIPmcdmZPeISk4C8M= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= +go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0 h1:v4KkRLVvE1cWqTJDfZZkTCG+Z4aolsa6RVos0FX7vqE= +go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0/go.mod h1:g/xbuPC0XbgwMdKuyF5sKOUUEsorSkN6APydyFP/H9E= +go.opentelemetry.io/contrib/propagators/b3 v1.46.0 h1:OFVqWObn7xLIbOjE/koO0LS9fZJNgAyBD0msA+UQAoc= +go.opentelemetry.io/contrib/propagators/b3 v1.46.0/go.mod h1:t/d64xy7xuuEDJN/4ThqohLgRhIuQxL9y7P1v02bYuM= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0 h1:Bu39F5tzJct+f2IZbB8989fwyTps3c8e7EsUQsz+vs8= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0/go.mod h1:dJUwod88EsFgYCqrDHaSPzhiY9pBUpt0d85/qSfua7k= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 h1:qkDYCAFiZXLcs1L4aY+tP2wguQ4kURANqHOQMA2et2s= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0/go.mod h1:tkipS4DRzmpAmvg+Gw4++O1IdDq6TVDnvnYU6cmbQVs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM= +go.opentelemetry.io/otel/log v0.22.0 h1:5DBNnfvaJ6CVdkJ+Jle8Tzs50aSSv49TXGj9XRsEYw0= +go.opentelemetry.io/otel/log v0.22.0/go.mod h1:gzOt/R67vF2GniAqWu8Qv0SXy89f71muHcrkz76PCdc= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU= +go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/log v0.22.0 h1:PRL+s6P63XT4E/bheEflopPUpVxuvANqZwtt89yhoGk= +go.opentelemetry.io/otel/sdk/log v0.22.0/go.mod h1:JNp0sBELrjCTcu5W3GzABVypeU6vDJjBS+X0JISuz+g= +go.opentelemetry.io/otel/sdk/log/logtest v0.22.0 h1:infPnfNrhCNgOUZRs3gWUg8vhoBUHihq02gwK05gzlg= +go.opentelemetry.io/otel/sdk/log/logtest v0.22.0/go.mod h1:gkQZA3z15Bv3KU9vigBTi8dFechSozRP7v94X4VZv+s= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU= +golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= +gorm.io/gorm v1.23.0/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +gorm.io/plugin/soft_delete v1.2.1 h1:qx9D/c4Xu6w5KT8LviX8DgLcB9hkKl6JC9f44Tj7cGU= +gorm.io/plugin/soft_delete v1.2.1/go.mod h1:Zv7vQctOJTGOsJ/bWgrN1n3od0GBAZgnLjEx+cApLGk= diff --git a/internal/dto/adreward.go b/internal/dto/adreward.go new file mode 100644 index 0000000..836f8d0 --- /dev/null +++ b/internal/dto/adreward.go @@ -0,0 +1,16 @@ +package dto + +// AdRewardGrantRequest 广告激励额度发放请求。 +// RewardType 取值:cos / ai / outfit_share / closet_share,映射到额度类型。 +// 发放金额由服务端配置固定,前端不传金额,杜绝刷额度。 +type AdRewardGrantRequest struct { + RewardType string `json:"reward_type" binding:"required"` // 激励类型 + RefID string `json:"ref_id" binding:"required"` // 广告记录ID(幂等键) +} + +// AdRewardGrantResponse 广告激励额度发放响应。 +type AdRewardGrantResponse struct { + RefID string `json:"ref_id"` // 广告记录ID + QuotaType int8 `json:"quota_type"` // 额度类型 1=cos 2=AI 3=社区分享 4=衣柜分享 + Amount int64 `json:"amount"` // 发放数量 +} diff --git a/internal/dto/airecommend.go b/internal/dto/airecommend.go new file mode 100644 index 0000000..764a449 --- /dev/null +++ b/internal/dto/airecommend.go @@ -0,0 +1,28 @@ +package dto + +// 发起AI推荐 +type AIRecommendCreateRequest struct { + SceneType int8 `json:"scene_type" binding:"required,oneof=1 2"` // 场景类型 1=每日推荐 2=个性化场景 + Scene string `json:"scene"` // 用户自定义场景描述 +} + +// AI推荐结果 +type AIRecommendCreateResponse struct { + OutfitUUID string `json:"outfit_uuid"` // 穿搭UUID + OutfitName string `json:"outfit_name"` // 穿搭名称 + OutfitDesc string `json:"outfit_desc"` // 穿搭描述 + Scene string `json:"scene"` // 适合场景 + Source int8 `json:"source"` // 来源 1=用户创建 2=AI推荐 + Status int8 `json:"status"` // 状态 1=正常 2=封禁 3=待用户保存 + OutfitItem []OutfitItem `json:"outfit_item"` +} + +// AI推荐穿搭的衣物UUID +type OutfitItem struct { + ClothesUUID string `json:"clothes_uuid"` // 衣物UUID +} + +// 保存AI推荐穿搭到我的搭配 +type AIRecommendSaveRequest struct { + OutfitUUID string `json:"outfit_uuid" binding:"required"` // 穿搭UUID +} diff --git a/internal/dto/closet.go b/internal/dto/closet.go new file mode 100644 index 0000000..46c41ab --- /dev/null +++ b/internal/dto/closet.go @@ -0,0 +1,52 @@ +package dto + +// ClosetShareCreateRequest 创建衣柜分享 +type ClosetShareCreateRequest struct { + ShareType int8 `json:"share_type" binding:"required,oneof=1 2"` // 1=部分衣物分享 2=全量快照分享 + ClothesUUIDs []string `json:"clothes_uuids"` // 部分分享时必填(service校验) + ExpireTime int64 `json:"expire_time"` // 过期时间 +} + +// ClosetShareCreateResponse 创建分享响应 +type ClosetShareCreateResponse struct { + ShareUUID string `json:"share_uuid"` +} + +// ClosetShareDetailResponse 分享详情(访客视角) +type ClosetShareDetailResponse struct { + ShareUUID string `json:"share_uuid"` + OwnerName string `json:"owner_name"` + OwnerAvatar string `json:"owner_avatar"` + Clothes []ClothesGetByUUIDResponse `json:"clothes"` + ExpireTime int64 `json:"expire_time"` + Status int8 `json:"status"` // 1=正常 2=已取消 3=已过期 4=已封禁 + IsOwner bool `json:"is_owner"` +} + +// ClosetShareItemResponse 分享列表项 +type ClosetShareItemResponse struct { + ShareUUID string `json:"share_uuid"` // 分享UUID + ShareType int8 `json:"share_type"` // 分享类型 1=部分衣物分享 2=全量快照分享 + ClothesCount int `json:"clothes_count"` // 衣物数量 + ExpireTime int64 `json:"expire_time"` // 过期时间 + Status int8 `json:"status"` // 1=正常 2=已取消 3=已过期 4=已封禁 + OwnerName string `json:"owner_name"` // 分享者昵称("分享给我的"列表返回) + OwnerAvatar string `json:"owner_avatar"` // 分享者头像("分享给我的"列表返回) +} + +// ShareListRequest 我的分享列表 +type ClosetShareListRequest struct { + PageRequest + Status int8 `form:"status" json:"status"` // 状态 1=正常 2=已取消 3=已过期 4=已封禁 +} + +// ShareListResponse 我的分享列表 +type ShareListResponse struct { + PageResponse + List []ClosetShareItemResponse `json:"list"` +} + +// ClosetShareCancelRequest 取消分享 +type ClosetShareCancelRequest struct { + ShareUUID string `json:"share_uuid" binding:"required"` +} diff --git a/internal/dto/clothes.go b/internal/dto/clothes.go new file mode 100644 index 0000000..1da82a4 --- /dev/null +++ b/internal/dto/clothes.go @@ -0,0 +1,91 @@ +package dto + +// ClothesUploadRequest 上传衣物图片 +type ClothesUploadRequest struct{} + +// ClothesUploadResponse 上传衣物图片响应 +type ClothesUploadResponse struct { + ClothesUUID string `json:"clothes_uuid"` // 衣物UUID + ImageProcessStatus int8 `json:"image_process_status"` // 1=抠图中 2=分类中 3=排队中 4=成功 5=失败 +} + +type ClothesProcessStatusGetByUUIDRequest struct { + ClothesUUID string `json:"clothes_uuid" binding:"required"` +} + +type ClothesProcessStatusGetByUUIDResponse struct { + ImageProcessStatus int8 `json:"image_process_status"` // 1=抠图中 2=分类中 3=排队中 4=成功 5=失败 + FailReason string `json:"fail_reason"` +} + +// ClothesDeleteByUUIDRequest 删除衣物 +type ClothesDeleteByUUIDRequest struct { + ClothesUUID string `json:"clothes_uuid" binding:"required"` +} + +// ClothesDeleteResponse 删除衣物响应 +type ClothesDeleteByUUIDResponse struct{} + +// ClothesUpdateByUUIDRequest 修改衣物 +type ClothesUpdateByUUIDRequest struct { + ClothesUUID string `json:"clothes_uuid" binding:"required"` + CategoryMain string `json:"category_main" binding:"max=64"` // 衣物大类 上装/下装/连衣裙/外套/鞋/配饰 + CategorySub string `json:"category_sub" binding:"max=64"` // 衣物小类 T恤/牛仔裤/运动鞋/发带/戒指等 + Style string `json:"style" binding:"max=64"` // 风格 + ColorMain string `json:"color" binding:"max=64"` // 主颜色 + ColorSub string `json:"color_sub" binding:"max=64"` // 副颜色 + Thickness string `json:"thickness" binding:"max=64"` // 厚度 + Version string `json:"version" binding:"max=64"` // 版型 宽松/修身/阔腿/紧身/常规合身 + Material string `json:"material" binding:"max=64"` // 材质 牛仔布/针织/皮质/纯棉/羊毛等 + Pattern string `json:"pattern" binding:"max=64"` // 花纹 + SleeveType string `json:"sleeve_type" binding:"max=64"` // 袖型 + ClothesType string `json:"clothes_type" binding:"max=64"` // 衣长类型 短款/常规/中长/长款 + NecklineType string `json:"neckline_type" binding:"max=64"` // 领型 + Formality string `json:"formality" binding:"max=16"` // 正式程度 1-5 + Occasions string `json:"occasions" binding:"max=255"` // 适用场合 + VisualImpact string `json:"visual_impact" binding:"max=16"` // 视觉冲击力 1-5 + Name string `json:"name" binding:"max=64"` // 名称 + Remark string `json:"remark" binding:"max=500"` // 备注 +} + +type ClothesUpdateByUUIDResponse struct{} + +type ClothesGetByUUIDRequest struct { + ClothesUUID string `json:"clothes_uuid" binding:"required"` // 衣物UUID +} + +// ClothesGetByUUIDResponse 衣物信息 +type ClothesGetByUUIDResponse struct { + ClothesUUID string `json:"clothes_uuid"` + CategoryMain string `json:"category_main"` + CategorySub string `json:"category_sub"` + Style string `json:"style"` + ColorMain string `json:"color_main"` + ColorSub string `json:"color_sub"` + Thickness string `json:"thickness"` + Version string `json:"version"` + Material string `json:"material"` + Pattern string `json:"pattern"` + SleeveType string `json:"sleeve_type"` + ClothesType string `json:"clothes_type"` + NecklineType string `json:"neckline_type"` + Formality string `json:"formality"` + Occasions string `json:"occasions"` + VisualImpact string `json:"visual_impact"` + Name string `json:"name"` + Remark string `json:"remark"` + ProcessedImage string `json:"processed_image"` +} + +// ClothesListRequest 衣物列表 +type ClothesListRequest struct { + PageRequest + CategoryMain string `form:"category_main"` // 按大类筛选 + CategorySub string `form:"category_sub"` +} + +// ClothesListResponse 衣物列表 +type ClothesListResponse struct { + PageResponse + List []ClothesGetByUUIDResponse `json:"list"` +} diff --git a/internal/dto/collect.go b/internal/dto/collect.go new file mode 100644 index 0000000..f774497 --- /dev/null +++ b/internal/dto/collect.go @@ -0,0 +1,29 @@ +package dto + +// 收藏/取消收藏 +type CollectRequest struct { + TargetUUID string `json:"target_uuid" binding:"required"` + Action int8 `json:"action" binding:"required,oneof=1 2"` // 1=收藏 2=取消 +} + +type CollectResponse struct{} + +type CollectListRequest struct { + PageRequest +} + +type CollectListResponse struct { + PageResponse + List []OutfitGetByUUIDResponse `json:"list"` +} + +// CollectGetByTargetRequest 查询穿搭收藏状态 +type CollectGetByTargetRequest struct { + TargetUUID string `json:"target_uuid" binding:"required"` // 穿搭UUID +} + +// CollectGetByTargetResponse 查询穿搭收藏状态 +type CollectGetByTargetResponse struct { + UserIsCollected bool `json:"is_collected"` // 是否已收藏 + CollectCount int64 `json:"collect_count"` // 收藏数 +} diff --git a/internal/dto/common.go b/internal/dto/common.go new file mode 100644 index 0000000..b108471 --- /dev/null +++ b/internal/dto/common.go @@ -0,0 +1,14 @@ +package dto + +// PageRequest 分页请求参数(GET query,嵌入各列表请求) +type PageRequest struct { + Page int `form:"page" binding:"omitempty,min=1"` + PageSize int `form:"page_size" binding:"omitempty,min=1,max=50"` +} + +// PageResponse 分页响应(嵌入各列表响应) +type PageResponse struct { + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} diff --git a/internal/dto/goods.go b/internal/dto/goods.go new file mode 100644 index 0000000..83617fd --- /dev/null +++ b/internal/dto/goods.go @@ -0,0 +1,34 @@ +package dto + +type GoodsGetByCodeRequest struct { + GoodsCode string `json:"goods_code" binding:"required"` +} + +// GoodsGetByCodeResponse 商品信息 +type GoodsGetByCodeResponse struct { + ID int64 `json:"id"` + GoodsCode string `json:"goods_code"` + GoodsName string `json:"goods_name"` + GoodsType int8 `json:"goods_type"` // 1=会员包 2=上传包 + Price int64 `json:"price"` // 原价(分) + DiscountPrice int64 `json:"discount_price"` // 售价(分) + MemberDays int64 `json:"member_days"` + UploadQuota int64 `json:"upload_quota"` + AIRecommendQuota int64 `json:"ai_recommend_quota"` + ClosetShareQuota int64 `json:"closet_share_quota"` + OutfitShareQuota int64 `json:"outfit_share_quota"` + ValidDays int64 `json:"valid_days"` // 赠送额度有效期天数(加量包;会员包为 0 表示跟随 VIP) + Description string `json:"description"` +} + +// GoodsListRequest 商品信息 +type GoodsListRequest struct { + PageRequest + GoodsType int8 `form:"goods_type" json:"goods_type"` // 1=会员包 2=上传包 3=AI推荐包 4=衣柜分享包 5=搭配分享包 +} + +// GoodsListResponse 商品列表 +type GoodsListResponse struct { + PageResponse + List []GoodsGetByCodeResponse `json:"list"` +} diff --git a/internal/dto/horoscope.go b/internal/dto/horoscope.go new file mode 100644 index 0000000..398a028 --- /dev/null +++ b/internal/dto/horoscope.go @@ -0,0 +1,16 @@ +package dto + +type HoroscopeRequest struct{} + +// HoroscopeResponse 今日运势 +type HoroscopeResponse struct { + StatDate int64 `json:"stat_date"` // YYYYMMDD + Zodiac string `json:"zodiac"` // 星座 + OverallScore int8 `json:"overall_score"` // 1-5分 + LoveScore int8 `json:"love_score"` // 1-5分 + CareerScore int8 `json:"career_score"` // 1-5分 + WealthScore int8 `json:"wealth_score"` // 1-5分 + DailyText string `json:"daily_text"` // 今日运势 + LuckyColor string `json:"lucky_color"` // 今日幸运色 + LuckyNumber string `json:"lucky_number"` // 今日幸运数字 +} diff --git a/internal/dto/like.go b/internal/dto/like.go new file mode 100644 index 0000000..fa77db1 --- /dev/null +++ b/internal/dto/like.go @@ -0,0 +1,21 @@ +package dto + +// LikeCreateRequest 点赞/取消点赞 +type LikeCreateRequest struct { + TargetUUID string `json:"target_uuid" binding:"required"` // 穿搭UUID + Action int8 `json:"action" binding:"required,oneof=1 2"` // 1=点赞 2=取消 +} + +// LikeResponse 点赞/取消点赞响应 +type LikeResponse struct{} + +// LikeGetByTargetIDRequest 获取穿搭的点赞数 +type LikeGetByTargetIDRequest struct { + TargetUUID string `json:"target_uuid" binding:"required"` // 穿搭UUID +} + +// LikeGetByTargetIDResponse 获取穿搭的点赞数 +type LikeGetByTargetIDResponse struct { + UserIsLiked bool `json:"is_liked"` // 是否已点赞 + LikeCount int64 `json:"like_count"` // 点赞数 +} diff --git a/internal/dto/notification.go b/internal/dto/notification.go new file mode 100644 index 0000000..868debf --- /dev/null +++ b/internal/dto/notification.go @@ -0,0 +1,39 @@ +package dto + +// NotificationListRequest 通知列表 +type NotificationListRequest struct { + PageRequest + IsRead int8 `form:"is_read" binding:"omitempty,oneof=1 2"` // 1=未读 2=已读,不传=全部 + MsgType int8 `form:"msg_type" binding:"omitempty,oneof=1 2 3"` // 1=点赞 2=订单 3=系统,不传=全部 +} + +// NotificationGetByUUIDRequest 获取通知详情 +type NotificationGetByUUIDRequest struct { + NotificationUUID string `json:"notification_uuid" binding:"required"` +} + +// NotificationGetByUUIDResponse 通知项 +type NotificationGetByUUIDResponse struct { + NotificationUUID string `json:"notification_uuid"` // 消息UUID + MsgType int8 `json:"msg_type"` // 1=点赞通知 2=订单通知 3=系统通知 + Title string `json:"title"` + Content string `json:"content"` + IsRead int8 `json:"is_read"` // 1=未读 2=已读 + CreatedAt int64 `json:"created_at"` +} + +// NotificationListResponse 通知列表 +type NotificationListResponse struct { + PageResponse + List []NotificationGetByUUIDResponse `json:"list"` +} + +// NotificationReadRequest 标记已读 +type NotificationReadRequest struct { + NotificationUUID string `json:"notification_uuid" binding:"required"` +} + +// NotificationUnreadResponse 未读数 +type NotificationUnreadResponse struct { + UnreadCount int64 `json:"unread_count"` +} diff --git a/internal/dto/order.go b/internal/dto/order.go new file mode 100644 index 0000000..78f560f --- /dev/null +++ b/internal/dto/order.go @@ -0,0 +1,86 @@ +package dto + +// OrderCreateRequest 创建订单 +type OrderCreateRequest struct { + GoodsCode string `json:"goods_code" binding:"required"` +} + +// OrderCreateResponse 创建订单响应 +type OrderCreateResponse struct { + OrderNo string `json:"order_no"` + PayAmount int64 `json:"pay_amount"` // 应付(分) + ExpireTime int64 `json:"expire_time"` // 支付截止(秒),前端倒计时 + PayParams *WechatPayParams `json:"pay_params"` // 小程序拉起支付参数 +} + +// WechatPayParams 微信小程序支付参数 +type WechatPayParams struct { + TimeStamp string `json:"timeStamp"` + NonceStr string `json:"nonceStr"` + Package string `json:"package"` + SignType string `json:"signType"` + PaySign string `json:"paySign"` +} + +// VirtualOrderCreateRequest 创建虚拟支付订单(小程序虚拟支付,覆盖 iOS/Android 全终端) +type VirtualOrderCreateRequest struct { + GoodsCode string `json:"goods_code" binding:"required"` // 商品编码 + Platform string `json:"platform" binding:"required,oneof=android ios harmony windows"` // 支付平台(前端按 systemInfo.platform 上报) +} + +// VirtualOrderCreateResponse 虚拟支付订单响应(前端据此调 wx.requestVirtualPayment) +type VirtualOrderCreateResponse struct { + OrderNo string `json:"order_no"` // 业务订单号(作为 outTradeNo) + PayAmount int64 `json:"pay_amount"` // 实付金额(分) + ExpireTime int64 `json:"expire_time"` // 支付截止(秒),前端倒计时 + SignData string `json:"sign_data"` // 参与签名的 JSON 字符串(原样传给前端) + PaySig string `json:"pay_sig"` // 支付签名 + Signature string `json:"signature"` // 用户态签名 +} + +// OrderResponse 订单信息 +type OrderResponse struct { + OrderNo string `json:"order_no"` + GoodsName string `json:"goods_name"` + UnitPrice int64 `json:"unit_price"` // 单价(分) + PayAmount int64 `json:"pay_amount"` // 实付(分) + PayChannel int8 `json:"pay_channel"` // 支付渠道 1=微信支付 2=虚拟支付 + OrderStatus int8 `json:"order_status"` // 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 + MemberDays int64 `json:"member_days"` + ExpireTime int64 `json:"expire_time"` + PayTime int64 `json:"pay_time"` + RefundTime int64 `json:"refund_time"` + RefundAmount int64 `json:"refund_amount"` + CreatedAt int64 `json:"created_at"` +} + +// OrderListRequest 订单列表 +type OrderListRequest struct { + PageRequest + Status int8 `form:"status" binding:"omitempty,oneof=1 2 3 4 5"` +} + +// OrderListResponse 订单列表 +type OrderListResponse struct { + PageResponse + List []OrderResponse `json:"list"` +} + +// OrderCancelRequest 取消订单 +type OrderCancelRequest struct { + OrderNo string `json:"order_no" binding:"required"` +} + +// RefundApplyRequest 申请退款 +type RefundApplyRequest struct { + OrderNo string `json:"order_no" binding:"required"` + Reason string `json:"reason" binding:"required,max=200"` +} + +// RefundResponse 退款信息 +type RefundResponse struct { + RefundNo string `json:"refund_no"` + RefundAmount int64 `json:"refund_amount"` + Status int8 `json:"status"` // 0=待处理 1=退款中 2=成功 3=失败 + ApplyTime int64 `json:"apply_time"` +} diff --git a/internal/dto/outfit.go b/internal/dto/outfit.go new file mode 100644 index 0000000..c235a02 --- /dev/null +++ b/internal/dto/outfit.go @@ -0,0 +1,83 @@ +package dto + +// OutfitCreateRequest 创建穿搭 +type OutfitCreateRequest struct { + OutfitName string `json:"outfit_name" binding:"required,max=32"` // 穿搭名称 + OutfitDesc string `json:"outfit_desc" binding:"max=500"` // 穿搭描述 + Scene string `json:"scene" binding:"max=100"` // 适合场景 + Source int8 `json:"source" binding:"oneof=1 2"` // 1=用户创建 2=AI推荐 + Items []OutfitItemRequest `json:"items" binding:"required,min=1,max=10"` +} + +// OutfitItemRequest 穿搭中的衣物 +type OutfitItemRequest struct { + ClothesUUID string `json:"clothes_uuid" binding:"required"` +} + +// OutfitCreateResponse 创建穿搭响应 +type OutfitCreateResponse struct { + OutfitUUID string `json:"outfit_uuid"` + OutfitName string `json:"outfit_name"` + OutfitDesc string `json:"outfit_desc"` + Scene string `json:"scene"` + Source int8 `json:"source"` // 1=用户创建 2=AI推荐 + IsPublic int8 `json:"is_public"` // 1=不公开 2=公开 + AuditStatus int8 `json:"audit_status"` // 1=待审核 2=通过 3=不通过 + Items []OutfitItemResponse `json:"items"` +} + +// OutfitItemResponse 穿搭明细 +type OutfitItemResponse struct { + ClothesUUID string `json:"clothes_uuid"` + ProcessedImage string `json:"processed_image"` // 处理后图片可访问 URL(无则空) +} + +// OutfitGetByUUIDRequest 获取穿搭详情 +type OutfitGetByUUIDRequest struct { + OutfitUUID string `json:"outfit_uuid" binding:"required"` // 穿搭UUID +} + +// OutfitGetByUUIDResponse 获取穿搭详情响应 +type OutfitGetByUUIDResponse struct { + OutfitCreateResponse +} + +// OutfitUpdateByUUIDRequest 修改穿搭 +type OutfitUpdateByUUIDRequest struct { + OutfitUUID string `json:"outfit_uuid" binding:"required"` // 穿搭UUID + OutfitName string `json:"outfit_name" binding:"required,max=32"` // 穿搭名称 + OutfitDesc string `json:"outfit_desc" binding:"max=500"` // 穿搭描述 + Scene string `json:"scene" binding:"max=100"` // 适合场景 + Items []OutfitItemRequest `json:"items" binding:"required,min=1,max=10"` // 穿搭中的衣物 +} + +// OutfitUpdateByUUIDResponse 修改穿搭响应 +type OutfitUpdateByUUIDResponse struct{} + +// OutfitListRequest 获取我的穿搭列表 +type OutfitListRequest struct { + PageRequest + IsPublic int8 `form:"is_public" json:"is_public"` // 1=不公开 2=公开 + Source int8 `form:"source" json:"source"` // 1=用户创建 2=AI推荐 +} + +// OutfitListResponse 我的穿搭列表响应 +type OutfitListResponse struct { + PageResponse + List []OutfitCreateResponse `json:"list"` +} + +// OutfitDeleteRequest 删除穿搭 +type OutfitDeleteRequest struct { + OutfitUUID string `json:"outfit_uuid" binding:"required"` +} + +// OutfitDeleteResponse 删除穿搭响应 +type OutfitDeleteResponse struct{} + +type OutfitPublicRequest struct { + OutfitUUID string `json:"outfit_uuid" binding:"required"` // 穿搭UUID + IsPublic int8 `json:"is_public" binding:"required,oneof=1 2"` // 1=不公开 2=公开 +} + +type OutfitPublicResponse struct{} diff --git a/internal/dto/quota.go b/internal/dto/quota.go new file mode 100644 index 0000000..57a3262 --- /dev/null +++ b/internal/dto/quota.go @@ -0,0 +1,44 @@ +package dto + +// QuotaInfoGetBySelfRequest 我的额度 +type QuotaInfoGetBySelfRequest struct{} + +// QuotaInfoGetBySelfResponse 我的额度 +type QuotaInfoGetBySelfResponse struct { + Quotas []QuotaItemResponse `json:"quotas"` +} + +// QuotaItemResponse 单项额度。 +// Cos(quota_type=1):Total=永久总额度、Used=永久累计已用、Remain=永久剩余; +// AI/社区/衣柜(2/3/4):Total=每日总额度、Used=当日已用、Remain=当日剩余(次日 0 点重置)。 +type QuotaItemResponse struct { + QuotaType int8 `json:"quota_type"` // 1=cos上传 2=AI 3=社区分享 4=衣柜分享 + Total int64 `json:"total"` // 总额度(Cos 永久 / 每日额度) + Used int64 `json:"used"` // 已用(Cos 永久累计 / 当日已用) + Remain int64 `json:"remain"` // 剩余 +} + +// QuotaFlowListRequest 额度流水查询 +type QuotaFlowListRequest struct { + PageRequest + QuotaType int8 `form:"quota_type" binding:"omitempty,oneof=1 2 3 4"` // 不传=全部 +} + +// QuotaFlowItemResponse 额度流水明细 +type QuotaFlowItemResponse struct { + QuotaFlowUUID string `json:"quota_flow_uuid"` // 配额流水UUID + QuotaType int8 `json:"quota_type"` // 1=cos上传 2=AI 3=社区分享 4=衣柜分享 + ChangeTarget int8 `json:"change_target"` // 1=配额调整 2=使用 + ChangeType int8 `json:"change_type"` // 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 + ChangeNum int64 `json:"change_num"` // 变更数量(正增负减) + NewValue int64 `json:"new_value"` // 变更后数值 + Reason string `json:"reason"` // 变更原因/备注 + OrderNo string `json:"order_no"` // 关联订单号(购买类) + CreatedAt int64 `json:"created_at"` +} + +// QuotaFlowListResponse 额度流水列表 +type QuotaFlowListResponse struct { + PageResponse + List []QuotaFlowItemResponse `json:"list"` +} diff --git a/internal/dto/user.go b/internal/dto/user.go new file mode 100644 index 0000000..b5ad4b2 --- /dev/null +++ b/internal/dto/user.go @@ -0,0 +1,68 @@ +package dto + +// UserWxLoginRequest 微信登录请求 +type UserWxLoginRequest struct { + Code string `json:"code" binding:"required"` // 微信登录code +} + +// UserWxLoginResponse 登录响应 +type UserWxLoginResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpireTime int64 `json:"expire_time"` // access_token过期时间(秒) + UserInfo UserInfoGetBySelfResponse `json:"user_info"` +} + +type UserInfoGetBySelfRequest struct{} + +// UserInfoGetBySelfResponse 用户信息 +type UserInfoGetBySelfResponse struct { + Username string `json:"username"` + Avatar string `json:"avatar"` + Sex int8 `json:"sex"` // 1=男 2=女 + BirthDay int64 `json:"birth_day"` // 生日 + Phone string `json:"phone"` // 手机号 + IsNewUser bool `json:"is_new_user"` // 是否本次新注册用户 + Status int8 `json:"status"` // 状态 1=正常 2=临时禁用 3=永久禁用 4=已注销 + BanReason string `json:"ban_reason"` // 封禁原因 + BanStart int64 `json:"ban_start"` // 封禁开始时间 + BanExpire int64 `json:"ban_expire"` // 封禁到期时间 + VIPType int8 `json:"vip_type"` // VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员 + VIPExpire int64 `json:"vip_expire"` // VIP到期时间 +} + +// UserProfileUpdateBySelfRequest 修改用户资料 +type UserProfileUpdateBySelfRequest struct { + Username string `json:"username" binding:"max=32"` // 昵称 + Avatar string `json:"avatar" binding:"max=500"` // 头像地址 + Sex int8 `json:"sex" binding:"omitempty,oneof=0 1 2"` // 0=未知 1=男 2=女 + BirthDay int64 `json:"birth_day"` // 生日 +} + +// UserProfileUpdateBySelfResponse 修改用户资料响应 +type UserProfileUpdateBySelfResponse struct{} + +// UserAvatarUploadRequest 上传头像(multipart,字段名 file) +type UserAvatarUploadRequest struct{} + +// UserAvatarUploadResponse 上传头像响应 +type UserAvatarUploadResponse struct { + Avatar string `json:"avatar"` // 头像可访问 URL +} + +// UserRefreshTokenRequest 刷新用户token请求 +type UserRefreshTokenRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` + // AccessToken 必填:旧 access token,用于刷新时立即吊销旧 access + 旧 refresh。 + // 会话以 token 字符串为 key 存在 Redis 中,缺少旧 access token 无法精确删除其会话 key, + // 旧 token 会在 TTL 内继续可用;为空时返回 10000 + details:["缺少旧 access token"]。 + // 注意:允许传入已过期的旧 access token(此时会话已随 TTL 消失,仅吊销旧 refresh 与集合条目)。 + AccessToken string `json:"access_token"` +} + +// UserRefreshTokenResponse 刷新用户token响应 +type UserRefreshTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpireTime int64 `json:"expire_time"` // access_token过期时间(秒) +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..9ac1ded --- /dev/null +++ b/internal/middleware/cors.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "strings" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "github.com/gin-gonic/gin" +) + +// CORS 跨域中间件。 +// 允许来源通过 Server.Domain 配置(如 test.com),自动放行其 http/https 两种协议的 Origin。 +// 未配置时回退为 *(保持兼容)。 +func CORS() gin.HandlerFunc { + domain := "" + if global.ServerSetting != nil { + domain = strings.TrimSpace(global.ServerSetting.Domain) + } + + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + allowOrigin := "*" + if domain != "" && domain != "*" { + allowOrigin = "" + if originAllowed(origin, domain) { + allowOrigin = origin + } + } + + if allowOrigin != "" { + c.Header("Access-Control-Allow-Origin", allowOrigin) + } + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization") + c.Header("Access-Control-Expose-Headers", "Content-Length") + c.Header("Access-Control-Max-Age", "86400") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +} + +// originAllowed 判断 Origin 是否命中配置域名(http/https,忽略尾部斜杠)。 +func originAllowed(origin, domain string) bool { + o := strings.TrimSuffix(strings.TrimSpace(origin), "/") + return o == "https://"+domain || o == "http://"+domain +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go new file mode 100644 index 0000000..273ecaf --- /dev/null +++ b/internal/middleware/logger.go @@ -0,0 +1,55 @@ +package middleware + +import ( + "log/slog" + "net/url" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// sanitizeQuery 对 query 中的敏感参数值脱敏,避免日志泄露 token/code/密钥等。 +func sanitizeQuery(query string) string { + if query == "" { + return "" + } + values, err := url.ParseQuery(query) + if err != nil { + return "***" + } + sensitive := map[string]bool{ + "token": true, "code": true, "secret": true, "password": true, + "access_token": true, "refresh_token": true, "key": true, + } + for k := range values { + if sensitive[strings.ToLower(k)] { + values.Set(k, "***") + } + } + return values.Encode() +} + +func GinSlogMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + + start := time.Now() + path := c.Request.URL.Path + query := sanitizeQuery(c.Request.URL.RawQuery) + + // 处理请求 + c.Next() + + // 请求结束后打日志 + slog.InfoContext(c.Request.Context(), "GIN_REQUEST", + "request_id", GetRequestID(c.Request.Context()), + "method", c.Request.Method, + "path", path, + "query", query, + "status", c.Writer.Status(), + "ip", c.ClientIP(), + "latency", time.Since(start).String(), + "user_agent", c.Request.UserAgent(), + ) + } +} diff --git a/internal/middleware/lzcwyjwt.go b/internal/middleware/lzcwyjwt.go new file mode 100644 index 0000000..b0b83f9 --- /dev/null +++ b/internal/middleware/lzcwyjwt.go @@ -0,0 +1,113 @@ +package middleware + +import ( + "context" + "errors" + "log/slog" + "strings" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/lzcwyjwt" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +type ctxKey string + +const ( + userIDKey ctxKey = "userID" +) + +func JWTAuth() gin.HandlerFunc { + return func(c *gin.Context) { + response := app.NewResponse(c) + + // 1. 从 Authorization 头取 token:Bearer xxx.yyy.zzz + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + response.ToErrorResponse(errcode.UnauthorizedTokenMissing) + c.Abort() + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("invalid Authorization format")) + c.Abort() + return + } + + // 2. 解析 token + claims, err := lzcwyjwt.ParseAccessToken(parts[1]) + if err != nil { + switch { + case errors.Is(err, lzcwyjwt.ErrTokenExpired): + response.ToErrorResponse(errcode.UnauthorizedTokenTimeout) + case errors.Is(err, lzcwyjwt.ErrTokenWrongType): + response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token type mismatch")) + default: + response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails(err.Error())) + } + c.Abort() + return + } + + // 2.5 校验 access token 是否仍在会话中(登出/失效后立即拒绝)。 + sessionUID, serr := redisdao.AuthUserIDByAccess(c.Request.Context(), parts[1]) + if serr != nil { + if errors.Is(serr, redis.Nil) { + response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token session invalid")) + c.Abort() + return + } + // Redis 其它错误降级放行(fail-open): + // 已确认的安全权衡决策——优先保证可用性,避免 Redis 抖动导致全员不可用。 + // 代价是 Redis 故障窗口内,已登出/被吊销但未过期的 token 仍可访问; + // 封禁用户仍会被下方 DB 回查(Status != 1)拒绝,不会因本降级放行而绕过封禁。 + slog.WarnContext(c.Request.Context(), "check access token session failed, allow by fallback", "error", serr) + } else if sessionUID != claims.UserID { + response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token user mismatch")) + c.Abort() + return + } + + // 3. 从缓存获取用户信息,未命中或 Redis 出错都回落 DB 并回填缓存,检查封禁状态 + userInfo, err := redisdao.UserInfoGet(c.Request.Context(), claims.UserID) + if err != nil { + if !errors.Is(err, redis.Nil) { + slog.WarnContext(c.Request.Context(), "get user info from cache failed, fallback to db", "error", err) + } + // 缓存未命中或 Redis 抖动:回落数据库。 + dbUser, dbErr := mysqldao.NewUserDao().UserGetByID(c.Request.Context(), claims.UserID) + if dbErr != nil { + response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("用户不存在")) + c.Abort() + return + } + userInfo = dbUser + _ = redisdao.UserInfoSet(c.Request.Context(), dbUser, 0) + } + + // 状态非正常(1=正常)一律拒绝 + if userInfo.Status != 1 { + response.ToErrorResponse(errcode.UserDisabled) + c.Abort() + return + } + + // 4. 把 userID注入 context + ctx := context.WithValue(c.Request.Context(), userIDKey, claims.UserID) + c.Request = c.Request.WithContext(ctx) + + c.Next() + } +} + +// GetUserID 从 context 取登录用户 ID(在 handler 里用) +func GetUserID(ctx context.Context) (int64, bool) { + uid, ok := ctx.Value(userIDKey).(int64) + return uid, ok +} diff --git a/internal/middleware/otel.go b/internal/middleware/otel.go new file mode 100644 index 0000000..7b92aea --- /dev/null +++ b/internal/middleware/otel.go @@ -0,0 +1,16 @@ +package middleware + +import ( + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "github.com/gin-gonic/gin" + "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" +) + +// GinOTelMiddleware 为每个 HTTP 请求自动开启 server span。 +func GinOTelMiddleware() gin.HandlerFunc { + name := "zt-closet-backend" + if global.OtelSetting != nil && global.OtelSetting.ServiceName != "" { + name = global.OtelSetting.ServiceName + } + return otelgin.Middleware(name) +} diff --git a/internal/middleware/requestid.go b/internal/middleware/requestid.go new file mode 100644 index 0000000..6c1b963 --- /dev/null +++ b/internal/middleware/requestid.go @@ -0,0 +1,37 @@ +package middleware + +import ( + "context" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +const requestIDKey ctxKey = "requestID" + +// RequestID 从请求头取 X-Request-ID,没有则生成 UUID。 +// 注入 context 并写入响应头,便于全链路追踪。 +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + rid := c.GetHeader("X-Request-ID") + if rid == "" { + rid = uuid.New().String() + } + + // 写入 gin context(供 handler 取用) + c.Set("requestID", rid) + // 写入 request context(供 service/dao 的 slog 取用) + ctx := context.WithValue(c.Request.Context(), requestIDKey, rid) + c.Request = c.Request.WithContext(ctx) + // 响应头 + c.Header("X-Request-ID", rid) + + c.Next() + } +} + +// GetRequestID 从 context 取出 request-id。 +func GetRequestID(ctx context.Context) string { + rid, _ := ctx.Value(requestIDKey).(string) + return rid +} diff --git a/internal/mysqldao/auditlog.go b/internal/mysqldao/auditlog.go new file mode 100644 index 0000000..a9f79c5 --- /dev/null +++ b/internal/mysqldao/auditlog.go @@ -0,0 +1,79 @@ +package mysqldao + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "gorm.io/gorm" +) + +// ClosetAuditLogDao 衣柜分享审核日志数据访问 +type ClosetAuditLogDao struct { + db *gorm.DB +} + +// NewClosetAuditLogDao 创建衣柜分享审核日志 DAO +func NewClosetAuditLogDao() *ClosetAuditLogDao { + return &ClosetAuditLogDao{db: global.MysqlEngine} +} + +// ClosetAuditLogCreate 创建衣柜分享审核日志 +func (d *ClosetAuditLogDao) ClosetAuditLogCreate(ctx context.Context, log *mysqlmodel.ClosetAuditLog) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetAuditLogCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(log).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// ClosetAuditLogListByTargetID 查询目标衣柜分享的审核日志 +func (d *ClosetAuditLogDao) ClosetAuditLogListByTargetID(ctx context.Context, targetID int64) ([]mysqlmodel.ClosetAuditLog, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetAuditLogListByTargetID") + defer span.End() + + var list []mysqlmodel.ClosetAuditLog + if err := d.db.WithContext(ctx).Where("target_id = ?", targetID).Find(&list).Error; err != nil { + span.RecordError(err) + return nil, err + } + return list, nil +} + +// OutfitAuditLogDao 穿搭审核日志数据访问 +type OutfitAuditLogDao struct { + db *gorm.DB +} + +// NewOutfitAuditLogDao 创建穿搭审核日志 DAO +func NewOutfitAuditLogDao() *OutfitAuditLogDao { + return &OutfitAuditLogDao{db: global.MysqlEngine} +} + +// OutfitAuditLogCreate 创建穿搭审核日志 +func (d *OutfitAuditLogDao) OutfitAuditLogCreate(ctx context.Context, log *mysqlmodel.OutfitAuditLog) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitAuditLogCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(log).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// OutfitAuditLogListByTargetID 查询目标穿搭的审核日志 +func (d *OutfitAuditLogDao) OutfitAuditLogListByTargetID(ctx context.Context, targetID int64) ([]mysqlmodel.OutfitAuditLog, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitAuditLogListByTargetID") + defer span.End() + + var list []mysqlmodel.OutfitAuditLog + if err := d.db.WithContext(ctx).Where("target_id = ?", targetID).Find(&list).Error; err != nil { + span.RecordError(err) + return nil, err + } + return list, nil +} diff --git a/internal/mysqldao/benefit_grant_task.go b/internal/mysqldao/benefit_grant_task.go new file mode 100644 index 0000000..d7bbe0d --- /dev/null +++ b/internal/mysqldao/benefit_grant_task.go @@ -0,0 +1,121 @@ +package mysqldao + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "gorm.io/gorm" +) + +// BenefitGrantTaskDao 权益发放补偿任务数据访问 +type BenefitGrantTaskDao struct { + db *gorm.DB +} + +// NewBenefitGrantTaskDao 创建补偿任务 DAO +func NewBenefitGrantTaskDao() *BenefitGrantTaskDao { + return &BenefitGrantTaskDao{db: global.MysqlEngine} +} + +// BenefitGrantTaskCreate 创建补偿任务,依赖唯一索引 (order_no, benefit_type) 防重复发放。 +func (d *BenefitGrantTaskDao) BenefitGrantTaskCreate(ctx context.Context, task *mysqlmodel.BenefitGrantTask) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(task).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// BenefitGrantTaskGetByOrderNoAndType 按订单号+权益类型查询补偿任务。 +func (d *BenefitGrantTaskDao) BenefitGrantTaskGetByOrderNoAndType(ctx context.Context, orderNo string, benefitType int8) (*mysqlmodel.BenefitGrantTask, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskGetByOrderNoAndType") + defer span.End() + + var task mysqlmodel.BenefitGrantTask + if err := d.db.WithContext(ctx). + Where("order_no = ? AND benefit_type = ?", orderNo, benefitType). + First(&task).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &task, nil +} + +// BenefitGrantTaskUpdateByID 按主键更新补偿任务字段。 +func (d *BenefitGrantTaskDao) BenefitGrantTaskUpdateByID(ctx context.Context, id int64, updates map[string]any) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskUpdateByID") + defer span.End() + + if err := d.db.WithContext(ctx).Model(&mysqlmodel.BenefitGrantTask{}).Where("id = ?", id).Updates(updates).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// BenefitGrantTaskListPending 查询待发放的补偿任务(status=0),按主键升序。 +func (d *BenefitGrantTaskDao) BenefitGrantTaskListPending(ctx context.Context, limit int) ([]mysqlmodel.BenefitGrantTask, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskListPending") + defer span.End() + + var list []mysqlmodel.BenefitGrantTask + if err := d.db.WithContext(ctx). + Where("status = ?", 0). + Order("id ASC"). + Limit(limit). + Find(&list).Error; err != nil { + span.RecordError(err) + return nil, err + } + return list, nil +} + +// BenefitGrantTaskReset 将失败任务重置为待发放(首次发放遇到 failed 记录时复用)。 +// 保留 retry_count:不归零,保证重试上限与人工介入机制不被重置绕过; +// 调用方应配合冷却期使用,避免高频回调无限重置。 +func (d *BenefitGrantTaskDao) BenefitGrantTaskReset(ctx context.Context, id int64) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskReset") + defer span.End() + + if err := d.db.WithContext(ctx).Model(&mysqlmodel.BenefitGrantTask{}). + Where("id = ?", id). + Updates(map[string]any{"status": 0, "last_error": ""}).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// BenefitGrantTaskClaim 条件认领任务(待发放/失败 -> 发放中),返回是否认领成功,防并发重复发放。 +func (d *BenefitGrantTaskDao) BenefitGrantTaskClaim(ctx context.Context, id int64) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskClaim") + defer span.End() + + res := d.db.WithContext(ctx).Model(&mysqlmodel.BenefitGrantTask{}). + Where("id = ? AND status IN (?, ?)", id, 0, 2). + Update("status", 3) + if res.Error != nil { + span.RecordError(res.Error) + return false, res.Error + } + return res.RowsAffected > 0, nil +} + +// BenefitGrantTaskRecoverStale 将「发放中」且超过一定时间未完成的任务重置为待发放(认领后崩溃的恢复)。 +func (d *BenefitGrantTaskDao) BenefitGrantTaskRecoverStale(ctx context.Context, beforeUpdatedAt int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.BenefitGrantTaskRecoverStale") + defer span.End() + + res := d.db.WithContext(ctx).Model(&mysqlmodel.BenefitGrantTask{}). + Where("status = ? AND updated_at < ?", 3, beforeUpdatedAt). + Update("status", 0) + if res.Error != nil { + span.RecordError(res.Error) + return 0, res.Error + } + return res.RowsAffected, nil +} diff --git a/internal/mysqldao/closet.go b/internal/mysqldao/closet.go new file mode 100644 index 0000000..d43c9d5 --- /dev/null +++ b/internal/mysqldao/closet.go @@ -0,0 +1,201 @@ +package mysqldao + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ClosetShareDao 衣柜分享数据访问 +type ClosetShareDao struct { + db *gorm.DB +} + +// NewClosetShareDao 创建衣柜分享 DAO +func NewClosetShareDao() *ClosetShareDao { + return &ClosetShareDao{db: global.MysqlEngine} +} + +// ClosetShareCreate 创建分享 +func (d *ClosetShareDao) ClosetShareCreate(ctx context.Context, share *mysqlmodel.ClosetShare) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(share).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// ClosetShareUpdateStatus 更新分享状态(取消/封禁) +func (d *ClosetShareDao) ClosetShareUpdateStatus(ctx context.Context, shareUUID string, status int8) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareUpdateStatus") + defer span.End() + + if err := d.db.WithContext(ctx).Model(&mysqlmodel.ClosetShare{}).Where("share_uuid = ?", shareUUID).UpdateColumn("status", status).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// ClosetShareGetByUUID 按 UUID 查询分享 +func (d *ClosetShareDao) ClosetShareGetByUUID(ctx context.Context, shareUUID string) (*mysqlmodel.ClosetShare, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareGetByUUID") + defer span.End() + + var share mysqlmodel.ClosetShare + if err := d.db.WithContext(ctx).Where("share_uuid = ?", shareUUID).First(&share).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &share, nil +} + +// ClosetShareListByUserID 分页查询我的分享(status 为 0 不筛选) +func (d *ClosetShareDao) ClosetShareListByUserID(ctx context.Context, userID int64, status int8, page, pageSize int) ([]mysqlmodel.ClosetShare, int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareListByUserID") + defer span.End() + + var list []mysqlmodel.ClosetShare + var total int64 + q := d.db.WithContext(ctx).Model(&mysqlmodel.ClosetShare{}).Where("user_id = ?", userID) + if status != 0 { + q = q.Where("status = ?", status) + } + if err := q.Count(&total).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + return list, total, nil +} + +// ClosetShareItemDao 分享明细数据访问 +type ClosetShareItemDao struct { + db *gorm.DB +} + +// NewClosetShareItemDao 创建分享明细 DAO +func NewClosetShareItemDao() *ClosetShareItemDao { + return &ClosetShareItemDao{db: global.MysqlEngine} +} + +// ClosetShareItemCreateBatch 批量创建分享明细 +func (d *ClosetShareItemDao) ClosetShareItemCreateBatch(ctx context.Context, items []mysqlmodel.ClosetShareItem) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareItemCreateBatch") + defer span.End() + + if len(items) == 0 { + return nil + } + if err := d.db.WithContext(ctx).Create(&items).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// ClosetShareItemDeleteByShareUUID 删除分享明细 +func (d *ClosetShareItemDao) ClosetShareItemDeleteByShareUUID(ctx context.Context, shareUUID string) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareItemDeleteByShareUUID") + defer span.End() + + if err := d.db.WithContext(ctx).Where("share_uuid = ?", shareUUID).Delete(&mysqlmodel.ClosetShareItem{}).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// ClosetShareItemListByShareUUID 查询分享明细 +func (d *ClosetShareItemDao) ClosetShareItemListByShareUUID(ctx context.Context, shareUUID string) ([]mysqlmodel.ClosetShareItem, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareItemListByShareUUID") + defer span.End() + + var list []mysqlmodel.ClosetShareItem + if err := d.db.WithContext(ctx).Where("share_uuid = ?", shareUUID).Find(&list).Error; err != nil { + span.RecordError(err) + return nil, err + } + return list, nil +} + +// ClosetShareItemListByShareUUIDs 批量查询分享明细 +func (d *ClosetShareItemDao) ClosetShareItemListByShareUUIDs(ctx context.Context, shareUUIDs []string) ([]mysqlmodel.ClosetShareItem, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareItemListByShareUUIDs") + defer span.End() + + var list []mysqlmodel.ClosetShareItem + if len(shareUUIDs) == 0 { + return list, nil + } + if err := d.db.WithContext(ctx).Where("share_uuid IN ?", shareUUIDs).Find(&list).Error; err != nil { + span.RecordError(err) + return nil, err + } + return list, nil +} + +// ClosetShareReceiverDao 衣柜分享接收人数据访问 +type ClosetShareReceiverDao struct { + db *gorm.DB +} + +// NewClosetShareReceiverDao 创建衣柜分享接收人 DAO +func NewClosetShareReceiverDao() *ClosetShareReceiverDao { + return &ClosetShareReceiverDao{db: global.MysqlEngine} +} + +// ClosetShareReceiverUpsert 幂等记录接收关系:唯一索引冲突时不做任何事,避免重复记录。 +func (d *ClosetShareReceiverDao) ClosetShareReceiverUpsert(ctx context.Context, shareUUID string, receiverUserID int64) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareReceiverUpsert") + defer span.End() + + receiver := &mysqlmodel.ClosetShareReceiver{ + ShareUUID: shareUUID, + ReceiverUserID: receiverUserID, + } + if err := d.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(receiver).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// ClosetShareReceivedList 联表分页查询某接收人收到的分享(status 为 0 不筛选)。 +func (d *ClosetShareReceiverDao) ClosetShareReceivedList(ctx context.Context, receiverUserID int64, status int8, page, pageSize int) ([]mysqlmodel.ClosetShare, int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareReceivedList") + defer span.End() + + var list []mysqlmodel.ClosetShare + var total int64 + + q := d.db.WithContext(ctx). + Model(&mysqlmodel.ClosetShare{}). + Joins("JOIN closet_share_receivers r ON r.share_uuid = closet_shares.share_uuid"). + Where("r.receiver_user_id = ?", receiverUserID) + if status != 0 { + q = q.Where("closet_shares.status = ?", status) + } + if err := q.Count(&total).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + if err := q.Select("closet_shares.*"). + Order("r.id DESC"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Find(&list).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + return list, total, nil +} diff --git a/internal/mysqldao/clothes.go b/internal/mysqldao/clothes.go new file mode 100644 index 0000000..fa3e7a6 --- /dev/null +++ b/internal/mysqldao/clothes.go @@ -0,0 +1,138 @@ +package mysqldao + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "gorm.io/gorm" +) + +// ClothesDao 衣物数据访问 +type ClothesDao struct { + db *gorm.DB +} + +// NewClothesDao 创建衣物 DAO +func NewClothesDao() *ClothesDao { + return &ClothesDao{db: global.MysqlEngine} +} + +// ClothesCreate 创建衣物 +func (d *ClothesDao) ClothesCreate(ctx context.Context, clothes *mysqlmodel.Clothes) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(clothes).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// ClothesDeleteByUUID 按 UUID 软删除衣物 +func (d *ClothesDao) ClothesDeleteByUUID(ctx context.Context, clothesUUID string) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesDeleteByUUID") + defer span.End() + + if err := d.db.WithContext(ctx).Where("clothes_uuid = ?", clothesUUID).Delete(&mysqlmodel.Clothes{}).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// ClothesUpdateByUUID 按 UUID 更新衣物字段 +func (d *ClothesDao) ClothesUpdateByUUID(ctx context.Context, clothesUUID string, updates map[string]any) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesUpdateByUUID") + defer span.End() + + if err := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}).Where("clothes_uuid = ?", clothesUUID).Updates(updates).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// ClothesGetByUUID 按 UUID 查询衣物 +func (d *ClothesDao) ClothesGetByUUID(ctx context.Context, clothesUUID string) (*mysqlmodel.Clothes, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesGetByUUID") + defer span.End() + + var clothes mysqlmodel.Clothes + if err := d.db.WithContext(ctx).Where("clothes_uuid = ?", clothesUUID).First(&clothes).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &clothes, nil +} + +// ClothesGetByID 按主键 ID 查询衣物 +func (d *ClothesDao) ClothesGetByID(ctx context.Context, id int64) (*mysqlmodel.Clothes, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesGetByID") + defer span.End() + + var clothes mysqlmodel.Clothes + if err := d.db.WithContext(ctx).First(&clothes, id).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &clothes, nil +} + +// ClothesGetByUUIDs 按 UUID 批量查询衣物 +func (d *ClothesDao) ClothesGetByUUIDs(ctx context.Context, uuids []string) ([]mysqlmodel.Clothes, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesGetByUUIDs") + defer span.End() + + var list []mysqlmodel.Clothes + if len(uuids) == 0 { + return list, nil + } + if err := d.db.WithContext(ctx).Where("clothes_uuid IN ?", uuids).Find(&list).Error; err != nil { + span.RecordError(err) + return nil, err + } + return list, nil +} + +// ClothesGetByIDs 按主键 ID 批量查询衣物 +func (d *ClothesDao) ClothesGetByIDs(ctx context.Context, ids []int64) ([]mysqlmodel.Clothes, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesGetByIDs") + defer span.End() + + var list []mysqlmodel.Clothes + if len(ids) == 0 { + return list, nil + } + if err := d.db.WithContext(ctx).Where("id IN ?", ids).Find(&list).Error; err != nil { + span.RecordError(err) + return nil, err + } + return list, nil +} + +// ClothesListByUserID 分页查询用户衣物(categoryMain/categorySub 为空不筛选) +func (d *ClothesDao) ClothesListByUserID(ctx context.Context, userID int64, categoryMain, categorySub string, page, pageSize int) ([]mysqlmodel.Clothes, int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesListByUserID") + defer span.End() + + var list []mysqlmodel.Clothes + var total int64 + q := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}).Where("user_id = ?", userID) + if categoryMain != "" { + q = q.Where("category_main = ?", categoryMain) + } + if categorySub != "" { + q = q.Where("category_sub = ?", categorySub) + } + if err := q.Count(&total).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + return list, total, nil +} diff --git a/internal/mysqldao/collect.go b/internal/mysqldao/collect.go new file mode 100644 index 0000000..24083f3 --- /dev/null +++ b/internal/mysqldao/collect.go @@ -0,0 +1,109 @@ +package mysqldao + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// CollectDao 收藏数据访问 +type CollectDao struct { + db *gorm.DB +} + +// NewCollectDao 创建收藏 DAO +func NewCollectDao() *CollectDao { + return &CollectDao{db: global.MysqlEngine} +} + +// CollectCreate 创建收藏记录 +func (d *CollectDao) CollectCreate(ctx context.Context, collect *mysqlmodel.Collect) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(collect).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// CollectUpsertOn 收藏幂等写入:唯一索引冲突时仅置 status=1,防并发重复插入。 +func (d *CollectDao) CollectUpsertOn(ctx context.Context, collect *mysqlmodel.Collect) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectUpsertOn") + defer span.End() + + if err := d.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "collect_type"}, {Name: "target_id"}, {Name: "user_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"status"}), + }).Create(collect).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// CollectUpdateStatus 更新收藏状态(1=已收藏 0=已取消) +func (d *CollectDao) CollectUpdateStatus(ctx context.Context, id int64, status int8) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectUpdateStatus") + defer span.End() + + if err := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}).Where("id = ?", id).UpdateColumn("status", status).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// CollectGetByTargetAndUser 查询用户在目标上的收藏记录 +func (d *CollectDao) CollectGetByTargetAndUser(ctx context.Context, collectType int8, targetID, userID int64) (*mysqlmodel.Collect, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectGetByTargetAndUser") + defer span.End() + + var collect mysqlmodel.Collect + if err := d.db.WithContext(ctx).Where("collect_type = ? AND target_id = ? AND user_id = ?", collectType, targetID, userID).First(&collect).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &collect, nil +} + +// CollectCountByTarget 统计目标收藏数(仅 status=1) +func (d *CollectDao) CollectCountByTarget(ctx context.Context, collectType int8, targetID int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectCountByTarget") + defer span.End() + + var count int64 + if err := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}). + Where("collect_type = ? AND target_id = ? AND status = ?", collectType, targetID, int8(1)). + Count(&count).Error; err != nil { + span.RecordError(err) + return 0, err + } + return count, nil +} + +// CollectListTargetIDsByUserPage 分页查询用户收藏的目标ID列表(仅 status=1),返回 ids 与总数。 +func (d *CollectDao) CollectListTargetIDsByUserPage(ctx context.Context, collectType int8, userID int64, page, pageSize int) ([]int64, int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.CollectListTargetIDsByUserPage") + defer span.End() + + q := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}). + Where("collect_type = ? AND user_id = ? AND status = ?", collectType, userID, int8(1)) + + var total int64 + if err := q.Count(&total).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + + var ids []int64 + if err := q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Pluck("target_id", &ids).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + return ids, total, nil +} diff --git a/internal/mysqldao/goods.go b/internal/mysqldao/goods.go new file mode 100644 index 0000000..4330eaa --- /dev/null +++ b/internal/mysqldao/goods.go @@ -0,0 +1,67 @@ +package mysqldao + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "gorm.io/gorm" +) + +// GoodsDao 商品数据访问 +type GoodsDao struct { + db *gorm.DB +} + +// NewGoodsDao 创建商品 DAO +func NewGoodsDao() *GoodsDao { + return &GoodsDao{db: global.MysqlEngine} +} + +// GoodsGetByCode 按商品编码查询商品 +func (d *GoodsDao) GoodsGetByCode(ctx context.Context, code string) (*mysqlmodel.Goods, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsGetByCode") + defer span.End() + + var goods mysqlmodel.Goods + if err := d.db.WithContext(ctx).Where("goods_code = ?", code).First(&goods).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &goods, nil +} + +// GoodsGetByID 按主键 ID 查询商品 +func (d *GoodsDao) GoodsGetByID(ctx context.Context, id int64) (*mysqlmodel.Goods, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsGetByID") + defer span.End() + + var goods mysqlmodel.Goods + if err := d.db.WithContext(ctx).First(&goods, id).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &goods, nil +} + +// GoodsList 分页查询上架商品(goodsType 为 0 不筛选) +func (d *GoodsDao) GoodsList(ctx context.Context, goodsType int8, page, pageSize int) ([]mysqlmodel.Goods, int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsList") + defer span.End() + + var list []mysqlmodel.Goods + var total int64 + q := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).Where("status = ?", int8(1)) + if goodsType != 0 { + q = q.Where("goods_type = ?", goodsType) + } + if err := q.Count(&total).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + return list, total, nil +} diff --git a/internal/mysqldao/horoscope.go b/internal/mysqldao/horoscope.go new file mode 100644 index 0000000..81df436 --- /dev/null +++ b/internal/mysqldao/horoscope.go @@ -0,0 +1,127 @@ +package mysqldao + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "gorm.io/gorm" +) + +// HoroscopeDao 运势数据访问 +type HoroscopeDao struct { + db *gorm.DB +} + +// NewHoroscopeDao 创建运势 DAO +func NewHoroscopeDao() *HoroscopeDao { + return &HoroscopeDao{db: global.MysqlEngine} +} + +// HoroscopeCreate 创建运势记录 +func (d *HoroscopeDao) HoroscopeCreate(ctx context.Context, horoscope *mysqlmodel.Horoscope) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.HoroscopeCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(horoscope).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// HoroscopeGetByZodiacAndDate 按星座和日期查询运势(幂等) +func (d *HoroscopeDao) HoroscopeGetByZodiacAndDate(ctx context.Context, zodiac string, statDate int64) (*mysqlmodel.Horoscope, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.HoroscopeGetByZodiacAndDate") + defer span.End() + + var horoscope mysqlmodel.Horoscope + if err := d.db.WithContext(ctx).Where("zodiac = ? AND stat_date = ?", zodiac, statDate).First(&horoscope).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &horoscope, nil +} + +// HoroscopeGetByDate 按日期查询当日全部星座运势。 +func (d *HoroscopeDao) HoroscopeGetByDate(ctx context.Context, statDate int64) ([]mysqlmodel.Horoscope, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.HoroscopeGetByDate") + defer span.End() + + var list []mysqlmodel.Horoscope + if err := d.db.WithContext(ctx).Where("stat_date = ?", statDate).Find(&list).Error; err != nil { + span.RecordError(err) + return nil, err + } + return list, nil +} + +// AIHoroscopeFlowLogDao 星座运势 AI 流水数据访问 +type AIHoroscopeFlowLogDao struct { + db *gorm.DB +} + +// NewAIHoroscopeFlowLogDao 创建星座运势 AI 流水 DAO +func NewAIHoroscopeFlowLogDao() *AIHoroscopeFlowLogDao { + return &AIHoroscopeFlowLogDao{db: global.MysqlEngine} +} + +// AIHoroscopeFlowLogCreate 创建星座运势 AI 流水 +func (d *AIHoroscopeFlowLogDao) AIHoroscopeFlowLogCreate(ctx context.Context, log *mysqlmodel.AIHoroscopeFlowLog) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.AIHoroscopeFlowLogCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(log).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// AIHoroscopeFlowLogGetByRequestID 按请求ID查询流水(幂等) +func (d *AIHoroscopeFlowLogDao) AIHoroscopeFlowLogGetByRequestID(ctx context.Context, requestID string) (*mysqlmodel.AIHoroscopeFlowLog, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.AIHoroscopeFlowLogGetByRequestID") + defer span.End() + + var log mysqlmodel.AIHoroscopeFlowLog + if err := d.db.WithContext(ctx).Where("request_id = ?", requestID).First(&log).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &log, nil +} + +// AIRecommendFlowLogDao AI 推荐流水数据访问 +type AIRecommendFlowLogDao struct { + db *gorm.DB +} + +// NewAIRecommendFlowLogDao 创建 AI 推荐流水 DAO +func NewAIRecommendFlowLogDao() *AIRecommendFlowLogDao { + return &AIRecommendFlowLogDao{db: global.MysqlEngine} +} + +// AIRecommendFlowLogCreate 创建 AI 推荐流水 +func (d *AIRecommendFlowLogDao) AIRecommendFlowLogCreate(ctx context.Context, log *mysqlmodel.AIRecommendFlowLog) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.AIRecommendFlowLogCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(log).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// AIRecommendFlowLogGetByRequestID 按请求ID查询流水(幂等) +func (d *AIRecommendFlowLogDao) AIRecommendFlowLogGetByRequestID(ctx context.Context, requestID string) (*mysqlmodel.AIRecommendFlowLog, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.AIRecommendFlowLogGetByRequestID") + defer span.End() + + var log mysqlmodel.AIRecommendFlowLog + if err := d.db.WithContext(ctx).Where("request_id = ?", requestID).First(&log).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &log, nil +} diff --git a/internal/mysqldao/like.go b/internal/mysqldao/like.go new file mode 100644 index 0000000..0d9d3f6 --- /dev/null +++ b/internal/mysqldao/like.go @@ -0,0 +1,102 @@ +package mysqldao + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// LikeDao 点赞数据访问 +type LikeDao struct { + db *gorm.DB +} + +// NewLikeDao 创建点赞 DAO +func NewLikeDao() *LikeDao { + return &LikeDao{db: global.MysqlEngine} +} + +// LikeCreate 创建点赞记录 +func (d *LikeDao) LikeCreate(ctx context.Context, like *mysqlmodel.Like) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(like).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// LikeUpsertOn 点赞幂等写入:唯一索引冲突时仅置 status=1,防并发重复插入。 +func (d *LikeDao) LikeUpsertOn(ctx context.Context, like *mysqlmodel.Like) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeUpsertOn") + defer span.End() + + if err := d.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "like_type"}, {Name: "target_id"}, {Name: "user_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"status"}), + }).Create(like).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// LikeUpdateStatus 更新点赞状态(1=已点赞 0=已取消) +func (d *LikeDao) LikeUpdateStatus(ctx context.Context, id int64, status int8) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeUpdateStatus") + defer span.End() + + if err := d.db.WithContext(ctx).Model(&mysqlmodel.Like{}).Where("id = ?", id).UpdateColumn("status", status).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// LikeGetByTargetAndUser 查询用户在目标上的点赞记录 +func (d *LikeDao) LikeGetByTargetAndUser(ctx context.Context, likeType int8, targetID, userID int64) (*mysqlmodel.Like, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeGetByTargetAndUser") + defer span.End() + + var like mysqlmodel.Like + if err := d.db.WithContext(ctx).Where("like_type = ? AND target_id = ? AND user_id = ?", likeType, targetID, userID).First(&like).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &like, nil +} + +// LikeCountByTarget 统计目标点赞数(仅 status=1) +func (d *LikeDao) LikeCountByTarget(ctx context.Context, likeType int8, targetID int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeCountByTarget") + defer span.End() + + var count int64 + if err := d.db.WithContext(ctx).Model(&mysqlmodel.Like{}). + Where("like_type = ? AND target_id = ? AND status = ?", likeType, targetID, int8(1)). + Count(&count).Error; err != nil { + span.RecordError(err) + return 0, err + } + return count, nil +} + +// LikeListUserIDsByTarget 查询点赞用户ID列表(仅 status=1) +func (d *LikeDao) LikeListUserIDsByTarget(ctx context.Context, likeType int8, targetID int64) ([]int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.LikeListUserIDsByTarget") + defer span.End() + + var ids []int64 + if err := d.db.WithContext(ctx).Model(&mysqlmodel.Like{}). + Where("like_type = ? AND target_id = ? AND status = ?", likeType, targetID, int8(1)). + Pluck("user_id", &ids).Error; err != nil { + span.RecordError(err) + return nil, err + } + return ids, nil +} diff --git a/internal/mysqldao/notification.go b/internal/mysqldao/notification.go new file mode 100644 index 0000000..150d8be --- /dev/null +++ b/internal/mysqldao/notification.go @@ -0,0 +1,114 @@ +package mysqldao + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "gorm.io/gorm" +) + +// NotificationDao 用户通知数据访问 +type NotificationDao struct { + db *gorm.DB +} + +// NewNotificationDao 创建通知 DAO +func NewNotificationDao() *NotificationDao { + return &NotificationDao{db: global.MysqlEngine} +} + +// NotificationCreate 创建通知 +func (d *NotificationDao) NotificationCreate(ctx context.Context, notification *mysqlmodel.UserNotification) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(notification).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// NotificationUpdateReadByUUID 标记通知已读 +func (d *NotificationDao) NotificationUpdateReadByUUID(ctx context.Context, notificationUUID string) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationUpdateReadByUUID") + defer span.End() + + if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserNotification{}). + Where("notification_uuid = ?", notificationUUID). + Update("is_read", int8(2)).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// NotificationUpdateReadByUUIDIfUnread 条件标记已读(仅未读 -> 已读),返回是否更新成功, +// 用于防并发重复扣减未读数。 +func (d *NotificationDao) NotificationUpdateReadByUUIDIfUnread(ctx context.Context, notificationUUID string) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationUpdateReadByUUIDIfUnread") + defer span.End() + + res := d.db.WithContext(ctx).Model(&mysqlmodel.UserNotification{}). + Where("notification_uuid = ? AND is_read = ?", notificationUUID, int8(1)). + Update("is_read", int8(2)) + if res.Error != nil { + span.RecordError(res.Error) + return false, res.Error + } + return res.RowsAffected > 0, nil +} + +// NotificationGetByUUID 按 UUID 查询通知 +func (d *NotificationDao) NotificationGetByUUID(ctx context.Context, notificationUUID string) (*mysqlmodel.UserNotification, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationGetByUUID") + defer span.End() + + var notification mysqlmodel.UserNotification + if err := d.db.WithContext(ctx).Where("notification_uuid = ?", notificationUUID).First(¬ification).Error; err != nil { + span.RecordError(err) + return nil, err + } + return ¬ification, nil +} + +// NotificationCountUnread 统计用户未读数 +func (d *NotificationDao) NotificationCountUnread(ctx context.Context, userID int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationCountUnread") + defer span.End() + + var count int64 + if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserNotification{}). + Where("user_id = ? AND is_read = ?", userID, int8(1)). + Count(&count).Error; err != nil { + span.RecordError(err) + return 0, err + } + return count, nil +} + +// NotificationListByUserID 分页查询用户通知(isRead/msgType 为 0 不筛选) +func (d *NotificationDao) NotificationListByUserID(ctx context.Context, userID int64, isRead, msgType int8, page, pageSize int) ([]mysqlmodel.UserNotification, int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationListByUserID") + defer span.End() + + var list []mysqlmodel.UserNotification + var total int64 + q := d.db.WithContext(ctx).Model(&mysqlmodel.UserNotification{}).Where("user_id = ?", userID) + if isRead != 0 { + q = q.Where("is_read = ?", isRead) + } + if msgType != 0 { + q = q.Where("msg_type = ?", msgType) + } + if err := q.Count(&total).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + return list, total, nil +} diff --git a/internal/mysqldao/order.go b/internal/mysqldao/order.go new file mode 100644 index 0000000..95d4fad --- /dev/null +++ b/internal/mysqldao/order.go @@ -0,0 +1,197 @@ +package mysqldao + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "gorm.io/gorm" +) + +// OrderDao 订单数据访问 +type OrderDao struct { + db *gorm.DB +} + +// NewOrderDao 创建订单 DAO +func NewOrderDao() *OrderDao { + return &OrderDao{db: global.MysqlEngine} +} + +// OrderCreate 创建订单 +func (d *OrderDao) OrderCreate(ctx context.Context, order *mysqlmodel.Order) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(order).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// OrderUpdateByOrderNo 按订单号更新订单字段 +func (d *OrderDao) OrderUpdateByOrderNo(ctx context.Context, orderNo string, updates map[string]any) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderUpdateByOrderNo") + defer span.End() + + if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).Where("order_no = ?", orderNo).Updates(updates).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// OrderUpdateStatusByOrderNo 条件流转订单状态(防重复回调),返回是否更新成功 +func (d *OrderDao) OrderUpdateStatusByOrderNo(ctx context.Context, orderNo string, fromStatus, toStatus int8) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderUpdateStatusByOrderNo") + defer span.End() + + res := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}). + Where("order_no = ? AND order_status = ?", orderNo, fromStatus). + Update("order_status", toStatus) + if res.Error != nil { + span.RecordError(res.Error) + return false, res.Error + } + return res.RowsAffected > 0, nil +} + +// OrderExpireByTime 将超过支付有效期的待支付订单批量置为已过期,返回更新条数。 +// 状态字面量:1=待支付 4=已过期(与 service 层 OrderStatusPending/OrderStatusExpired 一致)。 +func (d *OrderDao) OrderExpireByTime(ctx context.Context, now int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderExpireByTime") + defer span.End() + + res := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}). + Where("order_status = ? AND expire_time < ?", 1, now). + Update("order_status", 4) + if res.Error != nil { + span.RecordError(res.Error) + return 0, res.Error + } + return res.RowsAffected, nil +} + +// OrderGetByID 按主键 ID 查询订单 +func (d *OrderDao) OrderGetByID(ctx context.Context, id int64) (*mysqlmodel.Order, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderGetByID") + defer span.End() + + var order mysqlmodel.Order + if err := d.db.WithContext(ctx).First(&order, id).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &order, nil +} + +// OrderGetByOrderNo 按商户订单号查询订单 +func (d *OrderDao) OrderGetByOrderNo(ctx context.Context, orderNo string) (*mysqlmodel.Order, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderGetByOrderNo") + defer span.End() + + var order mysqlmodel.Order + if err := d.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&order).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &order, nil +} + +// OrderListByUserID 分页查询用户订单(status 为 0 不筛选) +func (d *OrderDao) OrderListByUserID(ctx context.Context, userID int64, status int8, page, pageSize int) ([]mysqlmodel.Order, int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderListByUserID") + defer span.End() + + var list []mysqlmodel.Order + var total int64 + q := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).Where("user_id = ?", userID) + if status != 0 { + q = q.Where("order_status = ?", status) + } + if err := q.Count(&total).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + return list, total, nil +} + +// RefundFlowLogDao 退款流水数据访问 +type RefundFlowLogDao struct { + db *gorm.DB +} + +// NewRefundFlowLogDao 创建退款流水 DAO +func NewRefundFlowLogDao() *RefundFlowLogDao { + return &RefundFlowLogDao{db: global.MysqlEngine} +} + +// RefundFlowLogCreate 创建退款流水 +func (d *RefundFlowLogDao) RefundFlowLogCreate(ctx context.Context, log *mysqlmodel.RefundFlowLog) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(log).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// RefundFlowLogUpdateStatus 按退款单号更新退款流水字段 +func (d *RefundFlowLogDao) RefundFlowLogUpdateStatus(ctx context.Context, refundNo string, updates map[string]any) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogUpdateStatus") + defer span.End() + + if err := d.db.WithContext(ctx).Model(&mysqlmodel.RefundFlowLog{}).Where("refund_no = ?", refundNo).Updates(updates).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// RefundFlowLogUpdateStatusByFrom 条件流转退款流水状态(防并发/乱序回调覆盖),返回是否更新成功。 +func (d *RefundFlowLogDao) RefundFlowLogUpdateStatusByFrom(ctx context.Context, refundNo string, fromStatus int8, updates map[string]any) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogUpdateStatusByFrom") + defer span.End() + + res := d.db.WithContext(ctx).Model(&mysqlmodel.RefundFlowLog{}). + Where("refund_no = ? AND status = ?", refundNo, fromStatus). + Updates(updates) + if res.Error != nil { + span.RecordError(res.Error) + return false, res.Error + } + return res.RowsAffected > 0, nil +} + +// RefundFlowLogGetByRefundNo 按退款单号查询退款流水 +func (d *RefundFlowLogDao) RefundFlowLogGetByRefundNo(ctx context.Context, refundNo string) (*mysqlmodel.RefundFlowLog, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogGetByRefundNo") + defer span.End() + + var log mysqlmodel.RefundFlowLog + if err := d.db.WithContext(ctx).Where("refund_no = ?", refundNo).First(&log).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &log, nil +} + +// RefundFlowLogGetByOrderNo 按商户订单号查询退款流水 +func (d *RefundFlowLogDao) RefundFlowLogGetByOrderNo(ctx context.Context, orderNo string) (*mysqlmodel.RefundFlowLog, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundFlowLogGetByOrderNo") + defer span.End() + + var log mysqlmodel.RefundFlowLog + if err := d.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&log).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &log, nil +} diff --git a/internal/mysqldao/outfit.go b/internal/mysqldao/outfit.go new file mode 100644 index 0000000..ea968cd --- /dev/null +++ b/internal/mysqldao/outfit.go @@ -0,0 +1,216 @@ +package mysqldao + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "gorm.io/gorm" +) + +// OutfitDao 穿搭数据访问 +type OutfitDao struct { + db *gorm.DB +} + +// NewOutfitDao 创建穿搭 DAO +func NewOutfitDao() *OutfitDao { + return &OutfitDao{db: global.MysqlEngine} +} + +// OutfitCreate 创建穿搭 +func (d *OutfitDao) OutfitCreate(ctx context.Context, outfit *mysqlmodel.Outfit) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(outfit).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// OutfitDeleteByUUID 按 UUID 软删除穿搭 +func (d *OutfitDao) OutfitDeleteByUUID(ctx context.Context, outfitUUID string) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitDeleteByUUID") + defer span.End() + + if err := d.db.WithContext(ctx).Where("outfit_uuid = ?", outfitUUID).Delete(&mysqlmodel.Outfit{}).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// OutfitUpdateByUUID 按 UUID 更新穿搭字段 +func (d *OutfitDao) OutfitUpdateByUUID(ctx context.Context, outfitUUID string, updates map[string]any) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitUpdateByUUID") + defer span.End() + + if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).Where("outfit_uuid = ?", outfitUUID).Updates(updates).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// OutfitUpdateIsPublic 设置穿搭公开状态 +func (d *OutfitDao) OutfitUpdateIsPublic(ctx context.Context, outfitUUID string, isPublic int8) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitUpdateIsPublic") + defer span.End() + + if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).Where("outfit_uuid = ?", outfitUUID).UpdateColumn("is_public", isPublic).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// OutfitPublish 条件公开穿搭(仅私密 -> 公开 + 审核通过),返回是否更新成功,防并发重复消耗额度。 +// 状态字面量:is_public 1=私密 2=公开;audit_status 2=审核通过。 +func (d *OutfitDao) OutfitPublish(ctx context.Context, outfitUUID string) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitPublish") + defer span.End() + + res := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}). + Where("outfit_uuid = ? AND is_public = ?", outfitUUID, int8(1)). + Updates(map[string]any{"is_public": int8(2), "audit_status": int8(2)}) + if res.Error != nil { + span.RecordError(res.Error) + return false, res.Error + } + return res.RowsAffected > 0, nil +} + +// OutfitGetByUUID 按 UUID 查询穿搭 +func (d *OutfitDao) OutfitGetByUUID(ctx context.Context, outfitUUID string) (*mysqlmodel.Outfit, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitGetByUUID") + defer span.End() + + var outfit mysqlmodel.Outfit + if err := d.db.WithContext(ctx).Where("outfit_uuid = ?", outfitUUID).First(&outfit).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &outfit, nil +} + +// OutfitGetByID 按主键 ID 查询穿搭 +func (d *OutfitDao) OutfitGetByID(ctx context.Context, id int64) (*mysqlmodel.Outfit, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitGetByID") + defer span.End() + + var outfit mysqlmodel.Outfit + if err := d.db.WithContext(ctx).First(&outfit, id).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &outfit, nil +} + +// OutfitGetByUUIDs 按 UUID 批量查询穿搭 +func (d *OutfitDao) OutfitGetByUUIDs(ctx context.Context, uuids []string) ([]mysqlmodel.Outfit, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitGetByUUIDs") + defer span.End() + + var list []mysqlmodel.Outfit + if len(uuids) == 0 { + return list, nil + } + if err := d.db.WithContext(ctx).Where("outfit_uuid IN ?", uuids).Find(&list).Error; err != nil { + span.RecordError(err) + return nil, err + } + return list, nil +} + +// OutfitListByUserID 分页查询用户穿搭(isPublic/source 为 0 不筛选) +func (d *OutfitDao) OutfitListByUserID(ctx context.Context, userID int64, isPublic, source int8, page, pageSize int) ([]mysqlmodel.Outfit, int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitListByUserID") + defer span.End() + + var list []mysqlmodel.Outfit + var total int64 + q := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).Where("user_id = ?", userID) + if isPublic != 0 { + q = q.Where("is_public = ?", isPublic) + } + if source != 0 { + q = q.Where("source = ?", source) + } + if err := q.Count(&total).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + return list, total, nil +} + +// OutfitItemDao 穿搭明细数据访问 +type OutfitItemDao struct { + db *gorm.DB +} + +// NewOutfitItemDao 创建穿搭明细 DAO +func NewOutfitItemDao() *OutfitItemDao { + return &OutfitItemDao{db: global.MysqlEngine} +} + +// OutfitItemCreateBatch 批量创建穿搭明细 +func (d *OutfitItemDao) OutfitItemCreateBatch(ctx context.Context, items []mysqlmodel.OutfitItem) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitItemCreateBatch") + defer span.End() + + if len(items) == 0 { + return nil + } + if err := d.db.WithContext(ctx).Create(&items).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// OutfitItemDeleteByOutfitID 删除穿搭明细 +func (d *OutfitItemDao) OutfitItemDeleteByOutfitID(ctx context.Context, outfitID int64) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitItemDeleteByOutfitID") + defer span.End() + + if err := d.db.WithContext(ctx).Where("outfit_id = ?", outfitID).Delete(&mysqlmodel.OutfitItem{}).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// OutfitItemListByOutfitID 查询穿搭明细 +func (d *OutfitItemDao) OutfitItemListByOutfitID(ctx context.Context, outfitID int64) ([]mysqlmodel.OutfitItem, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitItemListByOutfitID") + defer span.End() + + var list []mysqlmodel.OutfitItem + if err := d.db.WithContext(ctx).Where("outfit_id = ?", outfitID).Find(&list).Error; err != nil { + span.RecordError(err) + return nil, err + } + return list, nil +} + +// OutfitItemListByOutfitIDs 批量查询穿搭明细 +func (d *OutfitItemDao) OutfitItemListByOutfitIDs(ctx context.Context, outfitIDs []int64) ([]mysqlmodel.OutfitItem, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitItemListByOutfitIDs") + defer span.End() + + var list []mysqlmodel.OutfitItem + if len(outfitIDs) == 0 { + return list, nil + } + if err := d.db.WithContext(ctx).Where("outfit_id IN ?", outfitIDs).Find(&list).Error; err != nil { + span.RecordError(err) + return nil, err + } + return list, nil +} diff --git a/internal/mysqldao/quota.go b/internal/mysqldao/quota.go new file mode 100644 index 0000000..e6905a2 --- /dev/null +++ b/internal/mysqldao/quota.go @@ -0,0 +1,502 @@ +package mysqldao + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// QuotaDao 用户配额数据访问 +type QuotaDao struct { + db *gorm.DB +} + +// NewQuotaDao 创建配额 DAO +func NewQuotaDao() *QuotaDao { + return &QuotaDao{db: global.MysqlEngine} +} + +// QuotaCreate 创建配额记录 +func (d *QuotaDao) QuotaCreate(ctx context.Context, quota *mysqlmodel.Quota) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(quota).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// QuotaIncreaseUsed 原子增加已用量(扣减配额),剩余不足时返回 false。 +// 已废弃:仅改数值不写流水,存在账实不符风险;新代码请使用 QuotaConsumeWithFlow。 +func (d *QuotaDao) QuotaIncreaseUsed(ctx context.Context, userID int64, quotaType int8, n int64) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaIncreaseUsed") + defer span.End() + + res := d.db.WithContext(ctx).Model(&mysqlmodel.Quota{}). + Where("user_id = ? AND quota_type = ? AND used + ? <= total", userID, quotaType, n). + UpdateColumn("used", gorm.Expr("used + ?", n)) + if res.Error != nil { + span.RecordError(res.Error) + return false, res.Error + } + return res.RowsAffected > 0, nil +} + +// QuotaDecreaseUsed 原子减少已用量(回退配额),已用量不足时返回 false。 +// 已废弃:仅改数值不写流水,存在账实不符风险;新代码请使用 QuotaRefundWithFlow。 +func (d *QuotaDao) QuotaDecreaseUsed(ctx context.Context, userID int64, quotaType int8, n int64) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaDecreaseUsed") + defer span.End() + + res := d.db.WithContext(ctx).Model(&mysqlmodel.Quota{}). + Where("user_id = ? AND quota_type = ? AND used >= ?", userID, quotaType, n). + UpdateColumn("used", gorm.Expr("used - ?", n)) + if res.Error != nil { + span.RecordError(res.Error) + return false, res.Error + } + return res.RowsAffected > 0, nil +} + +// QuotaConsumeWithFlow 事务内扣减配额并写流水,保证数值变更与流水强一致。 +// 条件更新不满足(额度不足)返回 (false, nil);其余错误(含流水写入失败整体回滚)返回 error。 +func (d *QuotaDao) QuotaConsumeWithFlow(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaConsumeWithFlow") + defer span.End() + + var ok bool + err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + res := tx.Model(&mysqlmodel.Quota{}). + Where("user_id = ? AND quota_type = ? AND used + ? <= total", userID, quotaType, n). + UpdateColumn("used", gorm.Expr("used + ?", n)) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return nil // 额度不足,无变更。 + } + ok = true + + // 回读真实值填充流水 old/new。 + var cur mysqlmodel.Quota + if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil { + return err + } + remain := cur.Total - cur.Used + flow.OldValue = remain + n + flow.NewValue = remain + + // 写流水(失败则整个事务回滚,扣减一并撤销)。 + ensureFlowOrderNo(flow) + return tx.Create(flow).Error + }) + if err != nil { + span.RecordError(err) + return false, err + } + return ok, nil +} + +// QuotaRefundWithFlow 事务内回退配额并写流水,保证数值变更与流水强一致。 +// 条件更新不满足(已用量不足/重复回退)返回 (false, nil);其余错误(含流水失败回滚)返回 error。 +func (d *QuotaDao) QuotaRefundWithFlow(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaRefundWithFlow") + defer span.End() + + var ok bool + err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + res := tx.Model(&mysqlmodel.Quota{}). + Where("user_id = ? AND quota_type = ? AND used >= ?", userID, quotaType, n). + UpdateColumn("used", gorm.Expr("used - ?", n)) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return nil // 已用量不足,无变更。 + } + ok = true + + // 回读真实值填充流水 old/new。 + var cur mysqlmodel.Quota + if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil { + return err + } + remain := cur.Total - cur.Used + flow.OldValue = remain - n + flow.NewValue = remain + + // 写流水(失败则整个事务回滚,回退一并撤销)。 + ensureFlowOrderNo(flow) + return tx.Create(flow).Error + }) + if err != nil { + span.RecordError(err) + return false, err + } + return ok, nil +} + +// QuotaInitWithFlow 事务内初始化配额并写流水。 +// 依赖 uk_user_quota 唯一索引(OnConflict DoNothing)实现并发安全幂等:已存在则跳过且不写流水。 +func (d *QuotaDao) QuotaInitWithFlow(ctx context.Context, userID int64, quotaType int8, total int64, flow *mysqlmodel.QuotaFlowLog) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaInitWithFlow") + defer span.End() + + return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + q := &mysqlmodel.Quota{ + UserID: userID, + QuotaType: quotaType, + Total: total, + Used: 0, + } + res := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "quota_type"}}, + DoNothing: true, + }).Create(q) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return nil // 已存在,幂等跳过。 + } + + // 回读真实值填充流水 old/new。 + var cur mysqlmodel.Quota + if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil { + return err + } + remain := cur.Total - cur.Used + flow.OldValue = remain - total + flow.NewValue = remain + + // 写流水(失败则整个事务回滚,初始化一并撤销)。 + ensureFlowOrderNo(flow) + return tx.Create(flow).Error + }) +} + +// ensureFlowOrderNo 流水联合唯一索引 (order_no, quota_type, change_type) 的兜底: +// 消费/回退/初始化流水无业务订单号时(order_no 为空串),MySQL 唯一索引将空串视为相同值, +// 第二条同类流水会 1062 冲突。此处用流水自身 UUID 填充 order_no 保证唯一,不影响 +// 购买/退款类幂等流水(其 order_no 非空,直接沿用业务订单号)。 +func ensureFlowOrderNo(flow *mysqlmodel.QuotaFlowLog) { + if flow != nil && flow.OrderNo == "" { + flow.OrderNo = flow.QuotaFlowUUID + } +} + +// QuotaGrantIdempotent 幂等发放额度:事务内先累加 total(不存在则创建,存在则累加), +// 回读真实值后写流水;流水唯一索引(order_no+quota_type+change_type)冲突时事务回滚, +// 调用方据此识别为「已发放」从而幂等。 +func (d *QuotaDao) QuotaGrantIdempotent(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantIdempotent") + defer span.End() + + return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 1. 累加 total(记录不存在则创建,存在则原子累加)。 + q := &mysqlmodel.Quota{ + UserID: userID, + QuotaType: quotaType, + Total: n, + Used: 0, + } + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "quota_type"}}, + DoUpdates: clause.Assignments(map[string]interface{}{"total": gorm.Expr("total + ?", n)}), + }).Create(q).Error; err != nil { + return err + } + + // 2. 回读真实值填充流水 old/new。 + var cur mysqlmodel.Quota + if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil { + return err + } + remain := cur.Total - cur.Used + flow.OldValue = remain - n + flow.NewValue = remain + + // 3. 写流水(唯一索引冲突会回滚整个事务,保证不重复发放)。 + return tx.Create(flow).Error + }) +} + +// QuotaRevokeIdempotent 幂等回退额度(退款回收):事务内先扣减 total(GREATEST 保证 total 不低于 used, +// 即已用部分不回退),回读真实值后写回退流水;流水唯一索引(order_no+quota_type+change_type)冲突时 +// 事务回滚,调用方据此识别为「已回退」从而幂等。 +func (d *QuotaDao) QuotaRevokeIdempotent(ctx context.Context, userID int64, quotaType int8, n int64, flow *mysqlmodel.QuotaFlowLog) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaRevokeIdempotent") + defer span.End() + + return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 1. 扣减 total(GREATEST(used, total - n) 保证 total >= used)。 + res := tx.Model(&mysqlmodel.Quota{}). + Where("user_id = ? AND quota_type = ?", userID, quotaType). + UpdateColumn("total", gorm.Expr("GREATEST(used, total - ?)", n)) + if res.Error != nil { + return res.Error + } + + // 2. 回读真实值填充流水 old/new。 + var cur mysqlmodel.Quota + if err := tx.Where("user_id = ? AND quota_type = ?", userID, quotaType).First(&cur).Error; err != nil { + return err + } + remain := cur.Total - cur.Used + flow.OldValue = remain + n + flow.NewValue = remain + + // 3. 写回退流水(唯一索引冲突回滚整个事务,保证不重复回退)。 + return tx.Create(flow).Error + }) +} + +// QuotaGetByUserIDAndType 查询用户某类配额 +func (d *QuotaDao) QuotaGetByUserIDAndType(ctx context.Context, userID int64, quotaType int8) (*mysqlmodel.Quota, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGetByUserIDAndType") + defer span.End() + + var quota mysqlmodel.Quota + if err := d.db.WithContext(ctx).Where("user_id = ? AND quota_type = ?", userID, quotaType).First("a).Error; err != nil { + span.RecordError(err) + return nil, err + } + return "a, nil +} + +// QuotaListByUserID 查询用户的所有配额 +func (d *QuotaDao) QuotaListByUserID(ctx context.Context, userID int64) ([]mysqlmodel.Quota, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaListByUserID") + defer span.End() + + var quotas []mysqlmodel.Quota + if err := d.db.WithContext(ctx).Where("user_id = ?", userID).Find("as).Error; err != nil { + span.RecordError(err) + return nil, err + } + return quotas, nil +} + +// UserDailyUsageDao 每日使用计数数据访问 +type UserDailyUsageDao struct { + db *gorm.DB +} + +// NewUserDailyUsageDao 创建每日使用计数 DAO +func NewUserDailyUsageDao() *UserDailyUsageDao { + return &UserDailyUsageDao{db: global.MysqlEngine} +} + +// UserDailyUsageCreate 创建每日使用计数 +func (d *UserDailyUsageDao) UserDailyUsageCreate(ctx context.Context, usage *mysqlmodel.UserDailyUsage) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(usage).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// UserDailyUsageIncrementAI 当日AI推荐次数 +1 +func (d *UserDailyUsageDao) UserDailyUsageIncrementAI(ctx context.Context, userID int64, statDate int64) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageIncrementAI") + defer span.End() + + if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}). + Where("user_id = ? AND stat_date = ?", userID, statDate). + UpdateColumn("ai_recommend_count", gorm.Expr("ai_recommend_count + ?", 1)).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// UserDailyUsageIncrementOutfitShare 当日社区分享次数 +1 +func (d *UserDailyUsageDao) UserDailyUsageIncrementOutfitShare(ctx context.Context, userID int64, statDate int64) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageIncrementOutfitShare") + defer span.End() + + if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}). + Where("user_id = ? AND stat_date = ?", userID, statDate). + UpdateColumn("outfit_share_count", gorm.Expr("outfit_share_count + ?", 1)).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// UserDailyUsageIncrementClosetShare 当日衣柜分享次数 +1 +func (d *UserDailyUsageDao) UserDailyUsageIncrementClosetShare(ctx context.Context, userID int64, statDate int64) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageIncrementClosetShare") + defer span.End() + + if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}). + Where("user_id = ? AND stat_date = ?", userID, statDate). + UpdateColumn("closet_share_count", gorm.Expr("closet_share_count + ?", 1)).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// UserDailyUsageGetByUserIDAndDate 查询用户某日使用计数 +func (d *UserDailyUsageDao) UserDailyUsageGetByUserIDAndDate(ctx context.Context, userID int64, statDate int64) (*mysqlmodel.UserDailyUsage, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageGetByUserIDAndDate") + defer span.End() + + var usage mysqlmodel.UserDailyUsage + if err := d.db.WithContext(ctx).Where("user_id = ? AND stat_date = ?", userID, statDate).First(&usage).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &usage, nil +} + +// QuotaFlowLogDao 配额流水数据访问 +type QuotaFlowLogDao struct { + db *gorm.DB +} + +// NewQuotaFlowLogDao 创建配额流水 DAO +func NewQuotaFlowLogDao() *QuotaFlowLogDao { + return &QuotaFlowLogDao{db: global.MysqlEngine} +} + +// QuotaFlowLogCreate 创建配额流水 +func (d *QuotaFlowLogDao) QuotaFlowLogCreate(ctx context.Context, log *mysqlmodel.QuotaFlowLog) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaFlowLogCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(log).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// QuotaFlowLogExistsByOrderNoAndType 判断指定订单+额度类型是否已有发放流水(补偿重试幂等判断)。 +func (d *QuotaFlowLogDao) QuotaFlowLogExistsByOrderNoAndType(ctx context.Context, orderNo string, quotaType int8) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaFlowLogExistsByOrderNoAndType") + defer span.End() + + var count int64 + if err := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaFlowLog{}). + Where("order_no = ? AND quota_type = ?", orderNo, quotaType). + Count(&count).Error; err != nil { + span.RecordError(err) + return false, err + } + return count > 0, nil +} + +// QuotaFlowLogListByUserID 分页查询用户配额流水(quotaType 为 0 不筛选) +func (d *QuotaFlowLogDao) QuotaFlowLogListByUserID(ctx context.Context, userID int64, quotaType int8, page, pageSize int) ([]mysqlmodel.QuotaFlowLog, int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaFlowLogListByUserID") + defer span.End() + + var list []mysqlmodel.QuotaFlowLog + var total int64 + q := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaFlowLog{}).Where("user_id = ?", userID) + if quotaType != 0 { + q = q.Where("quota_type = ?", quotaType) + } + if err := q.Count(&total).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + return list, total, nil +} + +// QuotaGrantDao 额度凭证数据访问(有有效期赠送额度)。 +type QuotaGrantDao struct { + db *gorm.DB +} + +// NewQuotaGrantDao 创建额度凭证 DAO。 +func NewQuotaGrantDao() *QuotaGrantDao { + return &QuotaGrantDao{db: global.MysqlEngine} +} + +// QuotaGrantCreate 创建额度凭证(ref_id+quota_type 唯一索引兜底幂等,冲突返回 1062)。 +func (d *QuotaGrantDao) QuotaGrantCreate(ctx context.Context, grant *mysqlmodel.QuotaGrant) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(grant).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// QuotaGrantListByUserIDAndType 查询用户某类型全部凭证(未删除)。 +// 按到期时间升序(expire_time=0 表示跟随 VIP,排最后),配合「先到期先用」扣减策略。 +func (d *QuotaGrantDao) QuotaGrantListByUserIDAndType(ctx context.Context, userID int64, quotaType int8) ([]mysqlmodel.QuotaGrant, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantListByUserIDAndType") + defer span.End() + + var list []mysqlmodel.QuotaGrant + if err := d.db.WithContext(ctx). + Where("user_id = ? AND quota_type = ?", userID, quotaType). + Order("CASE WHEN expire_time = 0 THEN 1 ELSE 0 END ASC"). + Order("expire_time ASC"). + Find(&list).Error; err != nil { + span.RecordError(err) + return nil, err + } + return list, nil +} + +// QuotaGrantConsume 条件扣减单条凭证已用量(used + n <= amount),返回是否扣减成功。 +// 用于 Cos 永久额度凭证维度的扣减,避免并发超扣。 +func (d *QuotaGrantDao) QuotaGrantConsume(ctx context.Context, grantID int64, n int64) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantConsume") + defer span.End() + + res := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaGrant{}). + Where("id = ? AND used + ? <= amount", grantID, n). + UpdateColumn("used", gorm.Expr("used + ?", n)) + if res.Error != nil { + span.RecordError(res.Error) + return false, res.Error + } + return res.RowsAffected > 0, nil +} + +// QuotaGrantInvalidateByRefID 按关联ID软删除凭证(退款回收时使赠送额度失效)。 +func (d *QuotaGrantDao) QuotaGrantInvalidateByRefID(ctx context.Context, refID string) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantInvalidateByRefID") + defer span.End() + + if err := d.db.WithContext(ctx).Where("ref_id = ?", refID).Delete(&mysqlmodel.QuotaGrant{}).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// QuotaGrantExistsByRefIDAndType 判断凭证是否已存在(广告激励防重复领取)。 +func (d *QuotaGrantDao) QuotaGrantExistsByRefIDAndType(ctx context.Context, refID string, quotaType int8) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantExistsByRefIDAndType") + defer span.End() + + var count int64 + if err := d.db.WithContext(ctx).Model(&mysqlmodel.QuotaGrant{}). + Where("ref_id = ? AND quota_type = ?", refID, quotaType). + Count(&count).Error; err != nil { + span.RecordError(err) + return false, err + } + return count > 0, nil +} diff --git a/internal/mysqldao/user.go b/internal/mysqldao/user.go new file mode 100644 index 0000000..dbfa1d1 --- /dev/null +++ b/internal/mysqldao/user.go @@ -0,0 +1,176 @@ +package mysqldao + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "gorm.io/gorm" +) + +// UserDao 用户数据访问 +type UserDao struct { + db *gorm.DB +} + +// NewUserDao 创建用户 DAO +func NewUserDao() *UserDao { + return &UserDao{db: global.MysqlEngine} +} + +// UserCreate 创建用户 +func (d *UserDao) UserCreate(ctx context.Context, user *mysqlmodel.User) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.UserCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(user).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// UserUpdateByID 按主键 ID 更新用户字段 +func (d *UserDao) UserUpdateByID(ctx context.Context, userID int64, updates map[string]any) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.UserUpdateByID") + defer span.End() + + if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// VIPGrantIdempotent 幂等续费会员:事务内先原子累加 vip_expire(CASE 表达式避免并发丢失天数), +// 回读真实到期时间后推导会员类型并写 VIP 流水;流水唯一索引(order_no+change_type)冲突时事务回滚, +// 调用方据此识别为「已发放」从而幂等。 +// +// 注意:续费 SQL 使用原生语句显式控制 SET 顺序(vip_start 先于 vip_expire 求值), +// 使 vip_start 读到更新前的旧 vip_expire,保证「已过期续费时重置 vip_start」语义正确。 +// +// 会员类型按「续费后最终到期时长」推导:>=365 天为年度会员,否则月度会员, +// 避免「年度会员续月包被错误降级为月度」;体验会员等其它类型保持传入值不参与推导。 +func (d *UserDao) VIPGrantIdempotent(ctx context.Context, userID int64, now int64, memberDays int64, vipType int8, flow *mysqlmodel.VIPFlowLog) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPGrantIdempotent") + defer span.End() + + return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + sql := `UPDATE users SET + vip_start = CASE WHEN vip_type = 1 OR vip_expire <= ? THEN ? ELSE vip_start END, + vip_expire = (CASE WHEN vip_expire > ? THEN vip_expire ELSE ? END) + ? + WHERE id = ? AND deleted_at = 0` + if res := tx.Exec(sql, now, now, now, now, memberDays*86400, userID); res.Error != nil { + return res.Error + } + + // 回读续费后的真实到期时间,用于推导会员类型。 + var renewed mysqlmodel.User + if err := tx.Where("id = ?", userID).First(&renewed).Error; err != nil { + return err + } + + // 字面量 2=月度会员 3=年度会员(与 service 层 VIPTypeMonthly/VIPTypeYearly 一致)。 + // 月度/年度续费按最终到期时长推导;体验会员(4)等其它类型保持传入值。 + finalType := vipType + if vipType == 2 || vipType == 3 { + if renewed.VIPExpire-now >= 365*86400 { + finalType = 3 + } else { + finalType = 2 + } + } + if err := tx.Model(&mysqlmodel.User{}).Where("id = ?", userID). + UpdateColumn("vip_type", finalType).Error; err != nil { + return err + } + + flow.NewVIPExpire = renewed.VIPExpire + flow.NewVIPType = finalType + + return tx.Create(flow).Error + }) +} + +// VIPRevokeIdempotent 幂等回退会员(退款回收):事务内先扣减 vip_expire(GREATEST 保证不早于 now), +// 回读真实值后写回退流水;流水唯一索引(order_no+change_type)冲突时事务回滚,调用方据此识别为「已回退」。 +func (d *UserDao) VIPRevokeIdempotent(ctx context.Context, userID int64, now int64, memberDays int64, flow *mysqlmodel.VIPFlowLog) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPRevokeIdempotent") + defer span.End() + + return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 1. 扣减 vip_expire(GREATEST(now, vip_expire - days) 保证不倒退到过去)。 + res := tx.Model(&mysqlmodel.User{}). + Where("id = ? AND vip_expire > ?", userID, now). + UpdateColumn("vip_expire", gorm.Expr("GREATEST(?, vip_expire - ?)", now, memberDays*86400)) + if res.Error != nil { + return res.Error + } + + // 2. 回读真实值填充流水。 + var renewed mysqlmodel.User + if err := tx.Where("id = ?", userID).First(&renewed).Error; err != nil { + return err + } + flow.NewVIPExpire = renewed.VIPExpire + flow.NewVIPType = renewed.VIPType + + // 3. 写回退流水(唯一索引冲突回滚整个事务,保证不重复回退)。 + return tx.Create(flow).Error + }) +} + +// UserGetByID 按主键 ID 查询用户 +func (d *UserDao) UserGetByID(ctx context.Context, userID int64) (*mysqlmodel.User, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByID") + defer span.End() + + var user mysqlmodel.User + if err := d.db.WithContext(ctx).First(&user, userID).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &user, nil +} + +// UserGetByIDs 按主键 ID 批量查询用户(空 ids 直接返回空切片) +func (d *UserDao) UserGetByIDs(ctx context.Context, ids []int64) ([]mysqlmodel.User, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByIDs") + defer span.End() + + var users []mysqlmodel.User + if len(ids) == 0 { + return users, nil + } + if err := d.db.WithContext(ctx).Where("id IN ?", ids).Find(&users).Error; err != nil { + span.RecordError(err) + return nil, err + } + return users, nil +} + +// UserGetByUUID 按 UUID 查询用户 +func (d *UserDao) UserGetByUUID(ctx context.Context, userUUID string) (*mysqlmodel.User, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByUUID") + defer span.End() + + var user mysqlmodel.User + if err := d.db.WithContext(ctx).Where("user_uuid = ?", userUUID).First(&user).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &user, nil +} + +// UserGetByOpenID 按微信 openid 查询用户 +func (d *UserDao) UserGetByOpenID(ctx context.Context, openID string) (*mysqlmodel.User, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByOpenID") + defer span.End() + + var user mysqlmodel.User + if err := d.db.WithContext(ctx).Where("wx_open_id = ?", openID).First(&user).Error; err != nil { + span.RecordError(err) + return nil, err + } + return &user, nil +} diff --git a/internal/mysqldao/vipflowlog.go b/internal/mysqldao/vipflowlog.go new file mode 100644 index 0000000..2491e4b --- /dev/null +++ b/internal/mysqldao/vipflowlog.go @@ -0,0 +1,65 @@ +package mysqldao + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "gorm.io/gorm" +) + +// VIPFlowLogDao VIP 流水数据访问 +type VIPFlowLogDao struct { + db *gorm.DB +} + +// NewVIPFlowLogDao 创建 VIP 流水 DAO +func NewVIPFlowLogDao() *VIPFlowLogDao { + return &VIPFlowLogDao{db: global.MysqlEngine} +} + +// VIPFlowLogCreate 创建 VIP 流水 +func (d *VIPFlowLogDao) VIPFlowLogCreate(ctx context.Context, log *mysqlmodel.VIPFlowLog) error { + ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPFlowLogCreate") + defer span.End() + + if err := d.db.WithContext(ctx).Create(log).Error; err != nil { + span.RecordError(err) + return err + } + return nil +} + +// VIPFlowLogExistsByOrderNo 判断指定订单是否已有 VIP 发放流水(补偿重试幂等判断)。 +func (d *VIPFlowLogDao) VIPFlowLogExistsByOrderNo(ctx context.Context, orderNo string) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPFlowLogExistsByOrderNo") + defer span.End() + + var count int64 + if err := d.db.WithContext(ctx).Model(&mysqlmodel.VIPFlowLog{}). + Where("order_no = ?", orderNo). + Count(&count).Error; err != nil { + span.RecordError(err) + return false, err + } + return count > 0, nil +} + +// VIPFlowLogListByUserID 分页查询用户 VIP 流水 +func (d *VIPFlowLogDao) VIPFlowLogListByUserID(ctx context.Context, userID int64, page, pageSize int) ([]mysqlmodel.VIPFlowLog, int64, error) { + ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPFlowLogListByUserID") + defer span.End() + + var list []mysqlmodel.VIPFlowLog + var total int64 + q := d.db.WithContext(ctx).Model(&mysqlmodel.VIPFlowLog{}).Where("user_id = ?", userID) + if err := q.Count(&total).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil { + span.RecordError(err) + return nil, 0, err + } + return list, total, nil +} diff --git a/internal/mysqlmodel/aiflowlog.go b/internal/mysqlmodel/aiflowlog.go new file mode 100644 index 0000000..14b2e91 --- /dev/null +++ b/internal/mysqlmodel/aiflowlog.go @@ -0,0 +1,41 @@ +package mysqlmodel + +type AIRecommendFlowLog struct { + Model + RequestID string `gorm:"column:request_id"` // 请求唯一ID + ServerRequestID string `gorm:"column:server_request_id"` // 服务请求唯一ID + UserID int64 `gorm:"column:user_id"` // 用户ID + ModelVersion string `gorm:"column:model_version"` // 大模型版本 + PromptTokens int64 `gorm:"column:prompt_tokens"` // 输入token数 + CompletionTokens int64 `gorm:"column:completion_tokens"` // 输出token数 + ResultContent string `gorm:"column:result_content"` // 推荐结果结构化数据 + Status int8 `gorm:"column:status"` // 状态 1=成功 2=失败 + FailReason string `gorm:"column:fail_reason"` // 失败原因 + RequestTime int64 `gorm:"column:request_time"` // 请求发起时间 + ResponseTime int64 `gorm:"column:response_time"` // 响应返回时间 +} + +func (AIRecommendFlowLog) TableName() string { + return "ai_recommend_flow_logs" +} + +type AIHoroscopeFlowLog struct { + Model + RequestID string `gorm:"column:request_id"` // 请求唯一ID(幂等依据) + Zodiac string `gorm:"column:zodiac"` // 星座 + ServerRequestID string `gorm:"column:server_request_id"` // 服务请求唯一ID + StatDate int64 `gorm:"column:stat_date"` // 运势日期(YYYYMMDD) + ModelVersion string `gorm:"column:model_version"` // 大模型版本 + PromptTokens int64 `gorm:"column:prompt_tokens"` // 输入token数 + CompletionTokens int64 `gorm:"column:completion_tokens"` // 输出token数 + ResultContent string `gorm:"column:result_content"` // 生成结果结构化数据 + HoroscopeID int64 `gorm:"column:horoscope_id"` // 生成的运势记录ID + Status int8 `gorm:"column:status"` // 状态 1=成功 2=失败 + FailReason string `gorm:"column:fail_reason"` // 失败原因 + RequestTime int64 `gorm:"column:request_time"` // 请求发起时间 + ResponseTime int64 `gorm:"column:response_time"` // 响应返回时间 +} + +func (AIHoroscopeFlowLog) TableName() string { + return "ai_horoscope_flow_logs" +} diff --git a/internal/mysqlmodel/auditlog.go b/internal/mysqlmodel/auditlog.go new file mode 100644 index 0000000..2201cf5 --- /dev/null +++ b/internal/mysqlmodel/auditlog.go @@ -0,0 +1,25 @@ +package mysqlmodel + +type ClosetAuditLog struct { + Model + TargetID int64 `gorm:"column:target_id"` // 衣柜分享ID + AuditResult int8 `gorm:"column:audit_result"` // 审核结果 1=通过 2=驳回 + RejectReason string `gorm:"column:reject_reason"` // 审核原因 + AuditTime int64 `gorm:"column:audit_time"` // 审核时间 +} + +func (ClosetAuditLog) TableName() string { + return "closet_audit_logs" +} + +type OutfitAuditLog struct { + Model + TargetID int64 `gorm:"column:target_id"` // 穿搭ID + AuditResult int8 `gorm:"column:audit_result"` // 审核结果 1=通过 2=驳回 + RejectReason string `gorm:"column:reject_reason"` // 审核原因 + AuditTime int64 `gorm:"column:audit_time"` // 审核时间 +} + +func (OutfitAuditLog) TableName() string { + return "outfit_audit_logs" +} diff --git a/internal/mysqlmodel/benefit_grant_task.go b/internal/mysqlmodel/benefit_grant_task.go new file mode 100644 index 0000000..f3ada4e --- /dev/null +++ b/internal/mysqlmodel/benefit_grant_task.go @@ -0,0 +1,22 @@ +package mysqlmodel + +// BenefitGrantTask 权益发放补偿任务,用于支付成功后权益发放失败时的幂等补偿。 +// 唯一索引 (order_no, benefit_type) 作为幂等凭证,防止重复发放。 +type BenefitGrantTask struct { + Model + UserID int64 `gorm:"column:user_id"` // 用户ID + // OrderNo 商户订单号;与 BenefitType 组成联合唯一索引作为幂等凭证。 + OrderNo string `gorm:"column:order_no;uniqueIndex:uk_benefit_task_idem,priority:1"` + GoodsID int64 `gorm:"column:goods_id"` // 关联商品ID + // BenefitType 权益类型 1=vip 2=cos 3=ai 4=closet_share 5=outfit_share + BenefitType int8 `gorm:"column:benefit_type;uniqueIndex:uk_benefit_task_idem,priority:2"` + Amount int64 `gorm:"column:amount"` // 发放数量 + Status int8 `gorm:"column:status"` // 状态 0=待发放 1=已发放 2=发放失败 + RetryCount int `gorm:"column:retry_count"` // 已重试次数 + MaxRetry int `gorm:"column:max_retry"` // 最大重试次数 + LastError string `gorm:"column:last_error"` // 最近一次失败原因 +} + +func (BenefitGrantTask) TableName() string { + return "benefit_grant_tasks" +} diff --git a/internal/mysqlmodel/closet.go b/internal/mysqlmodel/closet.go new file mode 100644 index 0000000..32221f9 --- /dev/null +++ b/internal/mysqlmodel/closet.go @@ -0,0 +1,35 @@ +package mysqlmodel + +type ClosetShare struct { + Model + ShareUUID string `gorm:"column:share_uuid"` // 分享UUID + UserID int64 `gorm:"column:user_id"` // 所属用户ID + ShareType int8 `gorm:"column:share_type"` // 分享类型 1=部分衣物分享 2=全量快照分享 + ExpireTime int64 `gorm:"column:expire_time"` // 过期时间 + Status int8 `gorm:"column:status"` // 分享状态 1=正常 2=用户取消 3=已过期 4=管理员封禁 +} + +func (ClosetShare) TableName() string { + return "closet_shares" +} + +type ClosetShareItem struct { + Model + ShareUUID string `gorm:"column:share_uuid"` // 分享UUID + ClothesID int64 `gorm:"column:clothes_id"` // 衣物ID +} + +func (ClosetShareItem) TableName() string { + return "closet_share_items" +} + +// ClosetShareReceiver 衣柜分享接收人关联。 +type ClosetShareReceiver struct { + Model + ShareUUID string `gorm:"column:share_uuid;uniqueIndex:uk_closet_share_receiver,priority:1"` // 分享UUID + ReceiverUserID int64 `gorm:"column:receiver_user_id;uniqueIndex:uk_closet_share_receiver,priority:2"` // 接收人用户ID +} + +func (ClosetShareReceiver) TableName() string { + return "closet_share_receivers" +} diff --git a/internal/mysqlmodel/clothes.go b/internal/mysqlmodel/clothes.go new file mode 100644 index 0000000..f313df3 --- /dev/null +++ b/internal/mysqlmodel/clothes.go @@ -0,0 +1,32 @@ +package mysqlmodel + +type Clothes struct { + Model + ClothesUUID string `gorm:"column:clothes_uuid"` // 衣物UUID + UserID int64 `gorm:"column:user_id"` // 所属用户ID + CategoryMain string `gorm:"column:category_main"` // 衣物大类 上装/下装/连衣裙/外套/鞋/配饰 + CategorySub string `gorm:"column:category_sub"` // 衣物小类 T恤/牛仔裤/运动鞋/发带/戒指等 + Style string `gorm:"column:style"` // 风格 + ColorMain string `gorm:"column:color"` // 主颜色 + ColorSub string `gorm:"column:color_sub"` // 副颜色 + Thickness string `gorm:"column:thickness"` // 厚度 + Version string `gorm:"column:version"` // 版型 宽松/修身/阔腿/紧身/常规合身 + Material string `gorm:"column:material"` // 材质 牛仔布/针织/皮质/纯棉/羊毛等 + Pattern string `gorm:"column:pattern"` // 花纹 + SleeveType string `gorm:"column:sleeve_type"` // 袖型 + ClothesType string `gorm:"column:clothes_type"` // 衣长类型 短款/常规/中长/长款 + NecklineType string `gorm:"column:neckline_type"` // 领型 + Formality string `gorm:"column:formality"` // 正式程度 1-5 + Occasions string `gorm:"column:occasions"` // 适用场合 + VisualImpact string `gorm:"column:visual_impact"` // 视觉冲击力 1-5 + Name string `gorm:"column:name"` // 名称 + Remark string `gorm:"column:remark"` // 备注 + ProcessedImage string `gorm:"column:processed_image"` // 处理后图片存储位置 相对路径 + ImageSize int64 `gorm:"column:image_size"` // 图片大小 单位:kb + ImageProcessStatus int8 `gorm:"column:image_process_status"` // 处理状态 1=抠图中 2=分类中 3=排队中 4=成功 5=失败 + FailReason string `gorm:"column:fail_reason"` // 失败原因 +} + +func (Clothes) TableName() string { + return "clothes" +} diff --git a/internal/mysqlmodel/collect.go b/internal/mysqlmodel/collect.go new file mode 100644 index 0000000..a140243 --- /dev/null +++ b/internal/mysqlmodel/collect.go @@ -0,0 +1,14 @@ +package mysqlmodel + +type Collect struct { + Model + // CollectType 业务类型 1=社区穿搭;与 TargetID、UserID 组成联合唯一索引,支撑收藏/取消收藏 upsert。 + CollectType int8 `gorm:"column:collect_type;uniqueIndex:uk_collect_user_target,priority:1"` + TargetID int64 `gorm:"column:target_id;uniqueIndex:uk_collect_user_target,priority:2"` // 业务目标ID + UserID int64 `gorm:"column:user_id;uniqueIndex:uk_collect_user_target,priority:3"` // 收藏用户ID + Status int8 `gorm:"column:status"` // 状态 1=已收藏 0=已取消 +} + +func (Collect) TableName() string { + return "collects" +} diff --git a/internal/mysqlmodel/goods.go b/internal/mysqlmodel/goods.go new file mode 100644 index 0000000..8bead2f --- /dev/null +++ b/internal/mysqlmodel/goods.go @@ -0,0 +1,24 @@ +package mysqlmodel + +type Goods struct { + Model + GoodsCode string `gorm:"column:goods_code"` // 商品编码 + GoodsName string `gorm:"column:goods_name"` // 商品名称 + GoodsType int8 `gorm:"column:goods_type"` // 商品类型 1=会员包 2=上传包 3=AI推荐包 4=衣柜分享包 5=搭配分享包 + Price int64 `gorm:"column:price"` // 商品原价(分) + DiscountPrice int64 `gorm:"column:discount_price"` // 商品售价(分) + MemberDays int64 `gorm:"column:member_days"` // 赠送会员天数 + CosQuota int64 `gorm:"column:cos_quota"` // 赠送总上传额度 + AIRecommendQuota int64 `gorm:"column:ai_recommend_quota"` // 赠送AI推荐额度 + ClosetShareQuota int64 `gorm:"column:closet_share_quota"` // 赠送衣柜分享额度 + OutfitShareQuota int64 `gorm:"column:outfit_share_quota"` // 赠送搭配分享额度 + ValidDays int64 `gorm:"column:valid_days"` // 赠送额度有效期天数(加量包用;会员包为 0 表示跟随 VIP) + Description string `gorm:"column:description"` // 商品描述 + Status int8 `gorm:"column:status"` // 状态 1=上架 2=下架 + ProductID string `gorm:"column:product_id"` // 虚拟支付道具ID(空表示未发布) + ProductStatus int8 `gorm:"column:product_status"` // 虚拟支付道具发布状态 0=未发布 1=发布中 2=已发布 3=发布失败 +} + +func (Goods) TableName() string { + return "goods" +} diff --git a/internal/mysqlmodel/horoscope.go b/internal/mysqlmodel/horoscope.go new file mode 100644 index 0000000..1ee2d87 --- /dev/null +++ b/internal/mysqlmodel/horoscope.go @@ -0,0 +1,19 @@ +package mysqlmodel + +type Horoscope struct { + Model + // StatDate 日期(YYYYMMDD);与 Zodiac 组成联合唯一索引,保证每日每星座仅一条运势。 + StatDate int64 `gorm:"column:stat_date;uniqueIndex:uk_horoscope_date_zodiac,priority:1"` + Zodiac string `gorm:"column:zodiac;uniqueIndex:uk_horoscope_date_zodiac,priority:2"` // 星座 + OverallScore int8 `gorm:"column:overall_score"` // 综合运势 1-5分 + LoveScore int8 `gorm:"column:love_score"` // 爱情运势 + CareerScore int8 `gorm:"column:career_score"` // 工作运势 + WealthScore int8 `gorm:"column:wealth_score"` // 财富运势 + DailyText string `gorm:"column:daily_text"` // 今日运势详情 + LuckyColor string `gorm:"column:lucky_color"` // 幸运色 + LuckyNumber string `gorm:"column:lucky_number"` // 幸运数字 +} + +func (Horoscope) TableName() string { + return "horoscopes" +} diff --git a/internal/mysqlmodel/like.go b/internal/mysqlmodel/like.go new file mode 100644 index 0000000..8625657 --- /dev/null +++ b/internal/mysqlmodel/like.go @@ -0,0 +1,14 @@ +package mysqlmodel + +type Like struct { + Model + // LikeType 业务类型 1=社区穿搭;与 TargetID、UserID 组成联合唯一索引,支撑点赞/取消点赞 upsert。 + LikeType int8 `gorm:"column:like_type;uniqueIndex:uk_like_user_target,priority:1"` + TargetID int64 `gorm:"column:target_id;uniqueIndex:uk_like_user_target,priority:2"` // 穿搭ID + UserID int64 `gorm:"column:user_id;uniqueIndex:uk_like_user_target,priority:3"` // 点赞用户ID + Status int8 `gorm:"column:status"` // 状态 1=已点赞 0=已取消 +} + +func (Like) TableName() string { + return "likes" +} diff --git a/internal/mysqlmodel/model.go b/internal/mysqlmodel/model.go new file mode 100644 index 0000000..efd0a6d --- /dev/null +++ b/internal/mysqlmodel/model.go @@ -0,0 +1,54 @@ +package mysqlmodel + +import ( + "context" + "fmt" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/plugin/soft_delete" +) + +type Model struct { + ID int64 `gorm:"column:id;primaryKey"` // 主键ID + CreatedAt int64 `gorm:"column:created_at"` // 创建时间 + UpdatedAt int64 `gorm:"column:updated_at"` // 更新时间 + DeletedAt soft_delete.DeletedAt `gorm:"column:deleted_at"` // 删除时间 + CreatedBy int64 `gorm:"column:created_by"` // 创建人 + UpdatedBy int64 `gorm:"column:updated_by"` // 更新人 + DeletedBy int64 `gorm:"column:deleted_by"` // 删除人 +} + +// NewMysqlEngine 根据配置创建 GORM MySQL 连接并完成连接池初始化。 +func NewMysqlEngine(mysqlSetting *setting.MysqlSettingS) (*gorm.DB, error) { + s := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=10s&readTimeout=10s&writeTimeout=10s", + mysqlSetting.UserName, + mysqlSetting.Password, + mysqlSetting.Host, + mysqlSetting.Port, + mysqlSetting.DBName, + ) + db, err := gorm.Open(mysql.Open(s), &gorm.Config{}) + if err != nil { + return nil, err + } + + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + + sqlDB.SetMaxIdleConns(mysqlSetting.MaxIdleConns) + sqlDB.SetMaxOpenConns(mysqlSetting.MaxOpenConns) + sqlDB.SetConnMaxLifetime(time.Duration(mysqlSetting.ConnMaxLifeTime) * time.Second) + sqlDB.SetConnMaxIdleTime(time.Duration(mysqlSetting.ConnMaxIdleTime) * time.Second) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := sqlDB.PingContext(ctx); err != nil { + return nil, err + } + return db, nil +} diff --git a/internal/mysqlmodel/notification.go b/internal/mysqlmodel/notification.go new file mode 100644 index 0000000..8981a2c --- /dev/null +++ b/internal/mysqlmodel/notification.go @@ -0,0 +1,17 @@ +package mysqlmodel + +type UserNotification struct { + Model + NotificationUUID string `gorm:"column:notification_uuid"` // 消息UUID + UserID int64 `gorm:"column:user_id"` // 接收用户ID + MsgType int8 `gorm:"column:msg_type"` // 消息类型 1=点赞通知 2=订单通知 3=系统通知 + SenderID int64 `gorm:"column:sender_id"` // 发送者ID 系统消息为0 + Title string `gorm:"column:title"` // 消息标题 + Content string `gorm:"column:content"` // 消息内容 + IsRead int8 `gorm:"column:is_read"` // 是否已读 1=未读 2=已读 + ReadTime int64 `gorm:"column:read_time"` // 阅读时间 +} + +func (UserNotification) TableName() string { + return "user_notifications" +} diff --git a/internal/mysqlmodel/order.go b/internal/mysqlmodel/order.go new file mode 100644 index 0000000..0122b27 --- /dev/null +++ b/internal/mysqlmodel/order.go @@ -0,0 +1,34 @@ +package mysqlmodel + +type Order struct { + Model + // OrderNo 商户订单号;唯一索引保证订单号全局唯一。 + OrderNo string `gorm:"column:order_no;uniqueIndex:uk_order_no"` + UserID int64 `gorm:"column:user_id"` // 下单用户ID + GoodsID int64 `gorm:"column:goods_id"` // 关联商品ID + GoodsName string `gorm:"column:goods_name"` // 商品名称快照 + UnitPrice int64 `gorm:"column:unit_price"` // 商品单价(分) + TotalAmount int64 `gorm:"column:total_amount"` // 订单总金额(分) + PayAmount int64 `gorm:"column:pay_amount"` // 实付金额(分) + PayChannel int8 `gorm:"column:pay_channel"` // 支付渠道 1=微信支付 + OrderStatus int8 `gorm:"column:order_status"` // 订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款 + TransactionID string `gorm:"column:transaction_id"` // 微信支付流水号 + MemberDays int64 `gorm:"column:member_days"` // 购买的会员天数(快照) + CosQuota int64 `gorm:"column:cos_quota"` // 赠送上传额度(快照) + AIRecommendQuota int64 `gorm:"column:ai_recommend_quota"` // 赠送AI推荐额度(快照) + ClosetShareQuota int64 `gorm:"column:closet_share_quota"` // 赠送衣柜分享额度(快照) + OutfitShareQuota int64 `gorm:"column:outfit_share_quota"` // 赠送搭配分享额度(快照) + ExpireTime int64 `gorm:"column:expire_time"` // 支付过期时间 + PayTime int64 `gorm:"column:pay_time"` // 支付完成时间 + RefundTime int64 `gorm:"column:refund_time"` // 退款时间 + RefundAmount int64 `gorm:"column:refund_amount"` // 退款金额(分) + Remark string `gorm:"column:remark"` // 订单备注 + CallbackRaw string `gorm:"column:callback_raw"` // 支付回调原始报文 + XPayProductID string `gorm:"column:xpay_product_id"` // 虚拟支付道具ID(快照,pay_channel=2 时使用) + XPayPlatform string `gorm:"column:xpay_platform"` // 虚拟支付平台 android/ios/harmony/windows + XPayEnv int8 `gorm:"column:xpay_env"` // 虚拟支付环境 0=现网 1=沙箱 +} + +func (Order) TableName() string { + return "orders" +} diff --git a/internal/mysqlmodel/outfit.go b/internal/mysqlmodel/outfit.go new file mode 100644 index 0000000..86bd3ed --- /dev/null +++ b/internal/mysqlmodel/outfit.go @@ -0,0 +1,28 @@ +package mysqlmodel + +type Outfit struct { + Model + OutfitUUID string `gorm:"column:outfit_uuid"` // 穿搭UUID + UserID int64 `gorm:"column:user_id"` // 所属用户ID + OutfitName string `gorm:"column:outfit_name"` // 穿搭名称 + OutfitDesc string `gorm:"column:outfit_desc"` // 穿搭描述 + Scene string `gorm:"column:scene"` // 适合场景 + Source int8 `gorm:"column:source"` // 来源 1=用户创建 2=AI推荐 + IsPublic int8 `gorm:"column:is_public"` // 是否公开 1=不公开 2=公开 + AuditStatus int8 `gorm:"column:audit_status"` // 审核状态 1=待审核 2=审核通过 3=审核不通过 + Status int8 `gorm:"column:status"` // 状态 1=正常 2=封禁 3=待用户保存 +} + +func (Outfit) TableName() string { + return "outfits" +} + +type OutfitItem struct { + Model + OutfitID int64 `gorm:"column:outfit_id"` // 穿搭ID + ClothesID int64 `gorm:"column:clothes_id"` // 衣物ID +} + +func (OutfitItem) TableName() string { + return "outfit_items" +} diff --git a/internal/mysqlmodel/quota.go b/internal/mysqlmodel/quota.go new file mode 100644 index 0000000..5200b3e --- /dev/null +++ b/internal/mysqlmodel/quota.go @@ -0,0 +1,45 @@ +package mysqlmodel + +type Quota struct { + Model + UserID int64 `gorm:"column:user_id;uniqueIndex:uk_user_quota,priority:1"` // 用户ID + QuotaType int8 `gorm:"column:quota_type;uniqueIndex:uk_user_quota,priority:2"` // 1=cos 2=AI 3=社区分享 4=衣柜分享 + Total int64 `gorm:"column:total"` // 免费额度底值(仅 Cos 永久额度使用;AI/社区/衣柜 每日额度不再落库) + Used int64 `gorm:"column:used"` // 免费额度已用(仅 Cos 使用,永久累计) +} + +func (Quota) TableName() string { + return "user_quotas" +} + +// QuotaGrant 有有效期额度凭证(会员包/加量包/广告激励)。 +// AI/社区/衣柜 每日额度场景 Amount 表示「有效期内每天额外赠送次数」,Used 恒为 0; +// Cos 永久额度场景 Amount/Used 表示「一次性赠送库存及其已用」。 +type QuotaGrant struct { + Model + UserID int64 `gorm:"column:user_id;index:idx_grant_user_type_expire,priority:1"` // 用户ID + QuotaType int8 `gorm:"column:quota_type;index:idx_grant_user_type_expire,priority:2;uniqueIndex:uk_grant_ref_type,priority:2"` // 额度类型 1=cos 2=AI 3=社区分享 4=衣柜分享 + Amount int64 `gorm:"column:amount"` // 赠送总量 + Used int64 `gorm:"column:used"` // 已用量(仅 Cos 扣减) + Source int8 `gorm:"column:source"` // 来源 1=会员包 2=加量包 3=广告激励 + ExpireTime int64 `gorm:"column:expire_time;index:idx_grant_user_type_expire,priority:3"` // 到期时间(秒,0 表示跟随 VIP) + RefID string `gorm:"column:ref_id;uniqueIndex:uk_grant_ref_type,priority:1"` // 关联ID(订单号/广告记录ID) +} + +func (QuotaGrant) TableName() string { + return "quota_grants" +} + +type UserDailyUsage struct { + Model + // UserID 用户ID;与 StatDate 组成联合唯一索引,保证每日每用户仅一行。 + UserID int64 `gorm:"column:user_id;uniqueIndex:uk_daily_usage_user_date,priority:1"` + StatDate int64 `gorm:"column:stat_date;uniqueIndex:uk_daily_usage_user_date,priority:2"` // 统计日期(YYYYMMDD) + AIRecommendCount int64 `gorm:"column:ai_recommend_count"` // 当日AI推荐次数 + OutfitShareCount int64 `gorm:"column:outfit_share_count"` // 当日社区分享次数 + ClosetShareCount int64 `gorm:"column:closet_share_count"` // 当日衣柜分享次数 +} + +func (UserDailyUsage) TableName() string { + return "user_daily_usages" +} diff --git a/internal/mysqlmodel/quotaflowlog.go b/internal/mysqlmodel/quotaflowlog.go new file mode 100644 index 0000000..d32dc6f --- /dev/null +++ b/internal/mysqlmodel/quotaflowlog.go @@ -0,0 +1,23 @@ +package mysqlmodel + +// QuotaFlowLog 配额流水日志,统计用户配额变更情况 +type QuotaFlowLog struct { + Model + QuotaFlowUUID string `gorm:"column:quota_flow_uuid"` // 配额流水UUID + UserID int64 `gorm:"column:user_id"` // 用户ID + ChangeTarget int8 `gorm:"column:change_target"` // 调整额度类型 1=配额调整 2=使用 + // ChangeType 变更类型 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收 + ChangeType int8 `gorm:"column:change_type;uniqueIndex:uk_quota_flow_idem,priority:3"` + ChangeNum int64 `gorm:"column:change_num"` // 变更数量(正数增加,负数减少) + OldValue int64 `gorm:"column:old_value"` // 变更前数值 + NewValue int64 `gorm:"column:new_value"` // 变更后数值 + // OrderNo 关联订单号(购买/退款类);消费/回退/初始化类由 DAO 写入流水 UUID 保证联合唯一索引不冲突。 + OrderNo string `gorm:"column:order_no;uniqueIndex:uk_quota_flow_idem,priority:1"` + QuotaType int8 `gorm:"column:quota_type;uniqueIndex:uk_quota_flow_idem,priority:2"` // 关联业务类型 1=cos 2=AI 3=社区分享 4=衣柜分享 + RelatedID int64 `gorm:"column:related_id"` // 关联业务ID + Reason string `gorm:"column:reason"` // 变更原因/备注 +} + +func (QuotaFlowLog) TableName() string { + return "quota_flow_logs" +} diff --git a/internal/mysqlmodel/refundflowlog.go b/internal/mysqlmodel/refundflowlog.go new file mode 100644 index 0000000..7ded1ee --- /dev/null +++ b/internal/mysqlmodel/refundflowlog.go @@ -0,0 +1,22 @@ +package mysqlmodel + +type RefundFlowLog struct { + Model + OrderID int64 `gorm:"column:order_id"` // 关联订单ID + OrderNo string `gorm:"column:order_no"` // 商户订单号(冗余,方便查询) + UserID int64 `gorm:"column:user_id"` // 用户ID(冗余,方便按用户查) + // RefundNo 商户退款单号;唯一索引为退款申请防重/重试幂等依据。 + RefundNo string `gorm:"column:refund_no;uniqueIndex:uk_refund_flow_no"` + WechatRefundNo string `gorm:"column:wechat_refund_no"` // 微信退款单号 + RefundAmount int64 `gorm:"column:refund_amount"` // 本次退款金额(分) + RefundReason string `gorm:"column:refund_reason"` // 退款原因 + Status int8 `gorm:"column:status"` // 退款状态 0=待处理 1=退款中 2=退款成功 3=退款失败 + ApplyTime int64 `gorm:"column:apply_time"` // 申请时间 + SuccessTime int64 `gorm:"column:success_time"` // 退款成功时间 + FailReason string `gorm:"column:fail_reason"` // 失败原因 + CallbackRaw string `gorm:"column:callback_raw"` // 微信退款回调原始报文 +} + +func (RefundFlowLog) TableName() string { + return "refund_flow_logs" +} diff --git a/internal/mysqlmodel/user.go b/internal/mysqlmodel/user.go new file mode 100644 index 0000000..8711ac2 --- /dev/null +++ b/internal/mysqlmodel/user.go @@ -0,0 +1,25 @@ +package mysqlmodel + +type User struct { + Model + UserUUID string `gorm:"column:user_uuid"` // 用户UUID + WxOpenID string `gorm:"column:wx_open_id;uniqueIndex:uk_user_wx_open_id"` // 微信openid + Username string `gorm:"column:username"` // 用户名 + Avatar string `gorm:"column:avatar"` // 头像存储位置 + Sex int8 `gorm:"column:sex"` // 性别 1=男 2=女 + BirthDay int64 `gorm:"column:birth_day"` // 生日 + Phone string `gorm:"column:phone"` // 手机号 + IsNewUser bool `gorm:"column:is_new_user"` // 是否新用户 + Status int8 `gorm:"column:status"` // 状态 1=正常 2=临时禁用 3=永久禁用 4=已注销 + BanReason string `gorm:"column:ban_reason"` // 封禁原因 + BanStart int64 `gorm:"column:ban_start"` // 封禁开始时间 + BanExpire int64 `gorm:"column:ban_expire"` // 封禁到期时间 + VIPType int8 `gorm:"column:vip_type"` // VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员 + VIPStart int64 `gorm:"column:vip_start"` // VIP开始时间 + VIPExpire int64 `gorm:"column:vip_expire"` // VIP到期时间 + LastLoginTime int64 `gorm:"column:last_login_time"` // 最近登录时间 +} + +func (User) TableName() string { + return "users" +} diff --git a/internal/mysqlmodel/vipflowlog.go b/internal/mysqlmodel/vipflowlog.go new file mode 100644 index 0000000..2cb7a7b --- /dev/null +++ b/internal/mysqlmodel/vipflowlog.go @@ -0,0 +1,20 @@ +package mysqlmodel + +type VIPFlowLog struct { + Model + UserID int64 `gorm:"column:user_id"` // 用户ID + // ChangeType 变更类型 1=开通 2=续费 3=活动赠送 4=到期失效 5=人工调整 6=退款回收 + ChangeType int8 `gorm:"column:change_type;uniqueIndex:uk_vip_flow_idem,priority:2"` + OldVIPType int8 `gorm:"column:old_vip_type"` // 变更前VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员 + NewVIPType int8 `gorm:"column:new_vip_type"` // 变更后VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员 + OldVIPExpire int64 `gorm:"column:old_vip_expire"` // 变更前到期时间(秒) + NewVIPExpire int64 `gorm:"column:new_vip_expire"` // 变更后到期时间(秒) + // OrderNo 关联订单号(购买类变更);唯一索引 (order_no, change_type) 为 VIP 发放/回收幂等兜底。 + OrderNo string `gorm:"column:order_no;uniqueIndex:uk_vip_flow_idem,priority:1"` + Source int8 `gorm:"column:source"` // 变更来源 1=个人支付 2=活动赠送 3=系统 4=人工调整 + Reason string `gorm:"column:reason"` // 变更原因/备注 +} + +func (VIPFlowLog) TableName() string { + return "vip_flow_logs" +} diff --git a/internal/redisdao/auth.go b/internal/redisdao/auth.go new file mode 100644 index 0000000..a55f9ea --- /dev/null +++ b/internal/redisdao/auth.go @@ -0,0 +1,122 @@ +package redisdao + +import ( + "context" + "fmt" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" + "github.com/redis/go-redis/v9" +) + +// authTokenSAddScript 向用户 token 集合加入 refresh_token,仅在集合由空变为非空时设置 TTL, +// 避免每次登录都刷新集合 TTL 导致旧 token 条目长期驻留。 +const authTokenSAddScript = ` +local added = redis.call('SADD', KEYS[1], ARGV[1]) +if added == 1 and redis.call('SCARD', KEYS[1]) == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[2]) +end +return added +` + +// AuthTokenSet 登录成功后写入会话: +// - access_token -> user_id +// - refresh_token -> user_id +// - user_id 的 token 集合加入 refresh_token(用于多端管理 / 登出) +func AuthTokenSet(ctx context.Context, userID int64, accessToken, refreshToken string) error { + ctx, span := global.Tracer.Start(ctx, "redisdao.AuthTokenSet") + defer span.End() + + accessKey := fmt.Sprintf(redismodel.KeyAuthAccess, accessToken) + refreshKey := fmt.Sprintf(redismodel.KeyAuthRefresh, refreshToken) + userTokensKey := fmt.Sprintf(redismodel.KeyAuthUserTokens, userID) + + accessTTL := time.Duration(global.JWTSetting.AccessTokenExpire) * time.Second + refreshTTL := time.Duration(global.JWTSetting.RefreshTokenExpire) * time.Second + + pipe := global.RedisEngine.TxPipeline() + pipe.Set(ctx, accessKey, userID, accessTTL) + pipe.Set(ctx, refreshKey, userID, refreshTTL) + pipe.Eval(ctx, authTokenSAddScript, []string{userTokensKey}, refreshToken, int(refreshTTL.Seconds())) + if _, err := pipe.Exec(ctx); err != nil { + span.RecordError(err) + return err + } + return nil +} + +// AuthTokenDel 登出/失效:删除 access、refresh 映射,并从用户 token 集合移除 refresh_token +func AuthTokenDel(ctx context.Context, userID int64, accessToken, refreshToken string) error { + ctx, span := global.Tracer.Start(ctx, "redisdao.AuthTokenDel") + defer span.End() + + accessKey := fmt.Sprintf(redismodel.KeyAuthAccess, accessToken) + refreshKey := fmt.Sprintf(redismodel.KeyAuthRefresh, refreshToken) + userTokensKey := fmt.Sprintf(redismodel.KeyAuthUserTokens, userID) + + pipe := global.RedisEngine.TxPipeline() + pipe.Del(ctx, accessKey) + pipe.Del(ctx, refreshKey) + pipe.SRem(ctx, userTokensKey, refreshToken) + if _, err := pipe.Exec(ctx); err != nil { + span.RecordError(err) + return err + } + return nil +} + +// AuthUserIDByAccess 通过 access token 查询 user_id,token 不存在或已过期返回 redis.Nil +func AuthUserIDByAccess(ctx context.Context, accessToken string) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.AuthUserIDByAccess") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyAuthAccess, accessToken) + userID, err := global.RedisEngine.Get(ctx, key).Int64() + if err != nil && err != redis.Nil { + span.RecordError(err) + } + return userID, err +} + +// AuthUserIDByRefresh 通过 refresh token 查询 user_id,token 不存在或已过期返回 redis.Nil +func AuthUserIDByRefresh(ctx context.Context, refreshToken string) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.AuthUserIDByRefresh") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyAuthRefresh, refreshToken) + userID, err := global.RedisEngine.Get(ctx, key).Int64() + if err != nil && err != redis.Nil { + span.RecordError(err) + } + return userID, err +} + +// sessionKeyTTL session_key 缓存时长(30 天,与微信 session_key 有效期对齐)。 +const sessionKeyTTL = 30 * 24 * time.Hour + +// AuthSessionKeySet 写入用户的 session_key(登录时 code2session 返回,用于虚拟支付 signature 计算)。 +func AuthSessionKeySet(ctx context.Context, userID int64, sessionKey string) error { + ctx, span := global.Tracer.Start(ctx, "redisdao.AuthSessionKeySet") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyAuthSessionKey, userID) + if err := global.RedisEngine.Set(ctx, key, sessionKey, sessionKeyTTL).Err(); err != nil { + span.RecordError(err) + return err + } + return nil +} + +// AuthSessionKeyGet 读取用户的 session_key,不存在返回 redis.Nil。 +func AuthSessionKeyGet(ctx context.Context, userID int64) (string, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.AuthSessionKeyGet") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyAuthSessionKey, userID) + sessionKey, err := global.RedisEngine.Get(ctx, key).Result() + if err != nil && err != redis.Nil { + span.RecordError(err) + } + return sessionKey, err +} diff --git a/internal/redisdao/closet.go b/internal/redisdao/closet.go new file mode 100644 index 0000000..e0a6b9a --- /dev/null +++ b/internal/redisdao/closet.go @@ -0,0 +1,27 @@ +package redisdao + +import ( + "context" + "fmt" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" +) + +// viewCountTTL 访问计数 key 的过期时间,避免长期驻留导致内存无界增长。 +const viewCountTTL = 7 * 24 * time.Hour + +// ClosetShareViewIncr 衣柜分享访问计数 +1,返回最新值 +func ClosetShareViewIncr(ctx context.Context, shareUUID string) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.ClosetShareViewIncr") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyClosetShareView, shareUUID) + val, err := quotaDailyIncrScript.Run(ctx, global.RedisEngine, []string{key}, int(viewCountTTL.Seconds())).Int64() + if err != nil { + span.RecordError(err) + return 0, err + } + return val, nil +} diff --git a/internal/redisdao/collect.go b/internal/redisdao/collect.go new file mode 100644 index 0000000..1f28c01 --- /dev/null +++ b/internal/redisdao/collect.go @@ -0,0 +1,79 @@ +package redisdao + +import ( + "context" + "fmt" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" + "github.com/redis/go-redis/v9" +) + +// collectSetTTL 收藏集合 key 的过期时间,避免长期驻留导致与 MySQL 弱一致漂移。 +const collectSetTTL = 7 * 24 * time.Hour + +// CollectAdd 用户收藏(collect_type + target_id 分桶,成员为 user_id) +func CollectAdd(ctx context.Context, collectType int8, targetID, userID int64) error { + ctx, span := global.Tracer.Start(ctx, "redisdao.CollectAdd") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyCollectSet, collectType, targetID) + if err := global.RedisEngine.SAdd(ctx, key, userID).Err(); err != nil { + span.RecordError(err) + return err + } + // 设置 TTL(best-effort)。 + _ = global.RedisEngine.Expire(ctx, key, collectSetTTL).Err() + return nil +} + +// CollectRemove 用户取消收藏 +func CollectRemove(ctx context.Context, collectType int8, targetID, userID int64) error { + ctx, span := global.Tracer.Start(ctx, "redisdao.CollectRemove") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyCollectSet, collectType, targetID) + if err := global.RedisEngine.SRem(ctx, key, userID).Err(); err != nil { + span.RecordError(err) + return err + } + return nil +} + +// CollectIsMember 判断用户是否已收藏;集合 key 不存在(缓存过期/冷启动)时返回 redis.Nil, +// 由调用方回落 MySQL 以区分「未命中」与「确实未收藏」。 +func CollectIsMember(ctx context.Context, collectType int8, targetID, userID int64) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.CollectIsMember") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyCollectSet, collectType, targetID) + exists, err := global.RedisEngine.Exists(ctx, key).Result() + if err != nil { + span.RecordError(err) + return false, err + } + if exists == 0 { + return false, redis.Nil + } + ok, err := global.RedisEngine.SIsMember(ctx, key, userID).Result() + if err != nil { + span.RecordError(err) + return false, err + } + return ok, nil +} + +// CollectCount 获取收藏总数 +func CollectCount(ctx context.Context, collectType int8, targetID int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.CollectCount") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyCollectSet, collectType, targetID) + cnt, err := global.RedisEngine.SCard(ctx, key).Result() + if err != nil { + span.RecordError(err) + return 0, err + } + return cnt, nil +} diff --git a/internal/redisdao/horoscope.go b/internal/redisdao/horoscope.go new file mode 100644 index 0000000..0f2595f --- /dev/null +++ b/internal/redisdao/horoscope.go @@ -0,0 +1,37 @@ +package redisdao + +import ( + "context" + "fmt" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" + "github.com/redis/go-redis/v9" +) + +// HoroscopeSet 缓存星座运势内容(zodiac + 日期维度,data 由上层负责序列化) +func HoroscopeSet(ctx context.Context, zodiac string, statDate int64, data []byte, ttl time.Duration) error { + ctx, span := global.Tracer.Start(ctx, "redisdao.HoroscopeSet") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyHoroscope, zodiac, statDate) + if err := global.RedisEngine.Set(ctx, key, data, ttl).Err(); err != nil { + span.RecordError(err) + return err + } + return nil +} + +// HoroscopeGet 读取星座运势缓存,未命中返回 redis.Nil +func HoroscopeGet(ctx context.Context, zodiac string, statDate int64) ([]byte, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.HoroscopeGet") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyHoroscope, zodiac, statDate) + data, err := global.RedisEngine.Get(ctx, key).Bytes() + if err != nil && err != redis.Nil { + span.RecordError(err) + } + return data, err +} diff --git a/internal/redisdao/like.go b/internal/redisdao/like.go new file mode 100644 index 0000000..c1dcbc7 --- /dev/null +++ b/internal/redisdao/like.go @@ -0,0 +1,79 @@ +package redisdao + +import ( + "context" + "fmt" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" + "github.com/redis/go-redis/v9" +) + +// likeSetTTL 点赞集合 key 的过期时间,避免长期驻留导致与 MySQL 弱一致漂移。 +const likeSetTTL = 7 * 24 * time.Hour + +// LikeAdd 用户点赞(like_type + target_id 分桶,成员为 user_id) +func LikeAdd(ctx context.Context, likeType int8, targetID, userID int64) error { + ctx, span := global.Tracer.Start(ctx, "redisdao.LikeAdd") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyLikeSet, likeType, targetID) + if err := global.RedisEngine.SAdd(ctx, key, userID).Err(); err != nil { + span.RecordError(err) + return err + } + // 设置 TTL(best-effort)。 + _ = global.RedisEngine.Expire(ctx, key, likeSetTTL).Err() + return nil +} + +// LikeRemove 用户取消点赞 +func LikeRemove(ctx context.Context, likeType int8, targetID, userID int64) error { + ctx, span := global.Tracer.Start(ctx, "redisdao.LikeRemove") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyLikeSet, likeType, targetID) + if err := global.RedisEngine.SRem(ctx, key, userID).Err(); err != nil { + span.RecordError(err) + return err + } + return nil +} + +// LikeIsMember 判断用户是否已点赞;集合 key 不存在(缓存过期/冷启动)时返回 redis.Nil, +// 由调用方回落 MySQL 以区分「未命中」与「确实未点赞」。 +func LikeIsMember(ctx context.Context, likeType int8, targetID, userID int64) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.LikeIsMember") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyLikeSet, likeType, targetID) + exists, err := global.RedisEngine.Exists(ctx, key).Result() + if err != nil { + span.RecordError(err) + return false, err + } + if exists == 0 { + return false, redis.Nil + } + ok, err := global.RedisEngine.SIsMember(ctx, key, userID).Result() + if err != nil { + span.RecordError(err) + return false, err + } + return ok, nil +} + +// LikeCount 获取点赞总数 +func LikeCount(ctx context.Context, likeType int8, targetID int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.LikeCount") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyLikeSet, likeType, targetID) + cnt, err := global.RedisEngine.SCard(ctx, key).Result() + if err != nil { + span.RecordError(err) + return 0, err + } + return cnt, nil +} diff --git a/internal/redisdao/lock.go b/internal/redisdao/lock.go new file mode 100644 index 0000000..a621dd2 --- /dev/null +++ b/internal/redisdao/lock.go @@ -0,0 +1,44 @@ +package redisdao + +import ( + "context" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "github.com/redis/go-redis/v9" +) + +// unlockScript 仅当锁值匹配时才删除,防止误删其他持有者的锁 +var unlockScript = redis.NewScript(` +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) +else + return 0 +end +`) + +// TryLock 尝试获取分布式锁(SET NX EX),获取成功返回 true。 +// value 建议传唯一标识(如 request_id / uuid),用于释放时校验持有者。 +func TryLock(ctx context.Context, key, value string, ttl time.Duration) (bool, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.TryLock") + defer span.End() + + ok, err := global.RedisEngine.SetNX(ctx, key, value, ttl).Result() + if err != nil { + span.RecordError(err) + return false, err + } + return ok, nil +} + +// Unlock 释放分布式锁,仅当 value 匹配时才删除;锁不存在或不属于自己时静默返回。 +func Unlock(ctx context.Context, key, value string) error { + ctx, span := global.Tracer.Start(ctx, "redisdao.Unlock") + defer span.End() + + if err := unlockScript.Run(ctx, global.RedisEngine, []string{key}, value).Err(); err != nil { + span.RecordError(err) + return err + } + return nil +} diff --git a/internal/redisdao/notification.go b/internal/redisdao/notification.go new file mode 100644 index 0000000..92d8d0c --- /dev/null +++ b/internal/redisdao/notification.go @@ -0,0 +1,89 @@ +package redisdao + +import ( + "context" + "fmt" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" + "github.com/redis/go-redis/v9" +) + +// notificationUnreadTTL 未读数 key 的过期时间,避免长期驻留与 MySQL 弱一致漂移。 +const notificationUnreadTTL = 7 * 24 * time.Hour + +// notificationUnreadIncrScript 首次自增时设置过期时间,保证 INCR 与 EXPIRE 原子执行。 +var notificationUnreadIncrScript = redis.NewScript(` +local v = redis.call('INCR', KEYS[1]) +if v == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) +end +return v +`) + +// notificationUnreadDecrScript 未读数 -1,保证结果不低于 0 +var notificationUnreadDecrScript = redis.NewScript(` +local v = redis.call('GET', KEYS[1]) +if v and tonumber(v) > 0 then + return redis.call('DECRBY', KEYS[1], 1) +end +return 0 +`) + +// NotificationUnreadIncr 用户未读消息数 +1,返回最新值 +func NotificationUnreadIncr(ctx context.Context, userID int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadIncr") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyNotificationUnread, userID) + val, err := notificationUnreadIncrScript.Run(ctx, global.RedisEngine, []string{key}, int(notificationUnreadTTL.Seconds())).Int64() + if err != nil { + span.RecordError(err) + return 0, err + } + return val, nil +} + +// NotificationUnreadDecr 用户未读消息数 -1,返回扣减后的值(不低于 0) +func NotificationUnreadDecr(ctx context.Context, userID int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadDecr") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyNotificationUnread, userID) + val, err := notificationUnreadDecrScript.Run(ctx, global.RedisEngine, []string{key}).Int64() + if err != nil { + span.RecordError(err) + return 0, err + } + return val, nil +} + +// NotificationUnreadGet 获取用户未读消息数,不存在时返回 0 +func NotificationUnreadGet(ctx context.Context, userID int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadGet") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyNotificationUnread, userID) + v, err := global.RedisEngine.Get(ctx, key).Int64() + if err == redis.Nil { + return 0, nil + } + if err != nil { + span.RecordError(err) + } + return v, err +} + +// NotificationUnreadSet 回填未读数缓存(DB 对账回源后使用),避免 key 过期导致后续扣减被吞。 +func NotificationUnreadSet(ctx context.Context, userID int64, cnt int64) error { + ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadSet") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyNotificationUnread, userID) + if err := global.RedisEngine.Set(ctx, key, cnt, notificationUnreadTTL).Err(); err != nil { + span.RecordError(err) + return err + } + return nil +} diff --git a/internal/redisdao/outfit.go b/internal/redisdao/outfit.go new file mode 100644 index 0000000..39b79af --- /dev/null +++ b/internal/redisdao/outfit.go @@ -0,0 +1,23 @@ +package redisdao + +import ( + "context" + "fmt" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" +) + +// OutfitViewIncr 公开穿搭浏览计数 +1,返回最新值 +func OutfitViewIncr(ctx context.Context, outfitID int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.OutfitViewIncr") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyOutfitView, outfitID) + val, err := quotaDailyIncrScript.Run(ctx, global.RedisEngine, []string{key}, int(viewCountTTL.Seconds())).Int64() + if err != nil { + span.RecordError(err) + return 0, err + } + return val, nil +} diff --git a/internal/redisdao/quota.go b/internal/redisdao/quota.go new file mode 100644 index 0000000..49cfe7a --- /dev/null +++ b/internal/redisdao/quota.go @@ -0,0 +1,186 @@ +package redisdao + +import ( + "context" + "fmt" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" + "github.com/redis/go-redis/v9" +) + +// quotaDailyTTL 每日计数 key 的过期时间(跨天,保证到期自然过期) +const quotaDailyTTL = 48 * time.Hour + +// quotaDailyIncrScript 首次自增时设置过期时间,保证 INCR 与 EXPIRE 原子执行 +var quotaDailyIncrScript = redis.NewScript(` +local v = redis.call('INCR', KEYS[1]) +if v == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) +end +return v +`) + +// quotaDailyIncr 每日计数 +1 +func quotaDailyIncr(ctx context.Context, key string) (int64, error) { + return quotaDailyIncrScript.Run(ctx, global.RedisEngine, []string{key}, int(quotaDailyTTL.Seconds())).Int64() +} + +// quotaDailyValue 读取每日计数,key 不存在时返回 0 +func quotaDailyValue(ctx context.Context, key string) (int64, error) { + v, err := global.RedisEngine.Get(ctx, key).Int64() + if err == redis.Nil { + return 0, nil + } + return v, err +} + +// QuotaDailyAIIncr 当日 AI 推荐次数 +1 +func QuotaDailyAIIncr(ctx context.Context, userID int64, date int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyAIIncr") + defer span.End() + + val, err := quotaDailyIncr(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyAI, userID, date)) + if err != nil { + span.RecordError(err) + return 0, err + } + return val, nil +} + +// QuotaDailyOutfitShareIncr 当日社区分享(搭配分享)次数 +1 +func QuotaDailyOutfitShareIncr(ctx context.Context, userID int64, date int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyOutfitShareIncr") + defer span.End() + + val, err := quotaDailyIncr(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyOutfitShare, userID, date)) + if err != nil { + span.RecordError(err) + return 0, err + } + return val, nil +} + +// QuotaDailyClosetShareIncr 当日衣柜分享次数 +1 +func QuotaDailyClosetShareIncr(ctx context.Context, userID int64, date int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyClosetShareIncr") + defer span.End() + + val, err := quotaDailyIncr(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyClosetShare, userID, date)) + if err != nil { + span.RecordError(err) + return 0, err + } + return val, nil +} + +// QuotaDailyAIValue 当日 AI 推荐次数 +func QuotaDailyAIValue(ctx context.Context, userID int64, date int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyAIValue") + defer span.End() + + val, err := quotaDailyValue(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyAI, userID, date)) + if err != nil { + span.RecordError(err) + return 0, err + } + return val, nil +} + +// QuotaDailyOutfitShareValue 当日社区分享(搭配分享)次数 +func QuotaDailyOutfitShareValue(ctx context.Context, userID int64, date int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyOutfitShareValue") + defer span.End() + + val, err := quotaDailyValue(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyOutfitShare, userID, date)) + if err != nil { + span.RecordError(err) + return 0, err + } + return val, nil +} + +// QuotaDailyClosetShareValue 当日衣柜分享次数 +func QuotaDailyClosetShareValue(ctx context.Context, userID int64, date int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.QuotaDailyClosetShareValue") + defer span.End() + + val, err := quotaDailyValue(ctx, fmt.Sprintf(redismodel.KeyQuotaDailyClosetShare, userID, date)) + if err != nil { + span.RecordError(err) + return 0, err + } + return val, nil +} + +// ---- 通用每日额度原子限流(带限额) ---- + +// dailyQuotaConsumeScript 带限额的原子自增: +// KEYS[1]=当日计数 key;ARGV[1]=每日上限;ARGV[2]=消费数量;ARGV[3]=TTL 秒。 +// 当前计数 + n 超过上限返回 -1;否则 INCRBY 并在首次写入时设置 TTL,返回消费后的当日计数。 +var dailyQuotaConsumeScript = redis.NewScript(` +local v = tonumber(redis.call('GET', KEYS[1]) or '0') +local limit = tonumber(ARGV[1]) +local n = tonumber(ARGV[2]) +if v + n > limit then + return -1 +end +local r = redis.call('INCRBY', KEYS[1], n) +if v == 0 then + redis.call('EXPIRE', KEYS[1], ARGV[3]) +end +return r +`) + +// dailyQuotaRefundScript 回退当日计数(DECRBY 且不小于 0)。 +// ARGV[1]=回退数量。 +var dailyQuotaRefundScript = redis.NewScript(` +local v = tonumber(redis.call('GET', KEYS[1]) or '0') +local n = tonumber(ARGV[1]) +if v <= 0 then + return 0 +end +if n > v then + n = v +end +return redis.call('DECRBY', KEYS[1], n) +`) + +// DailyQuotaConsume 原子消费当日额度。 +// 返回 (消费后计数, nil);额度不足返回 (-1, nil);Redis 异常返回 (0, err)。 +func DailyQuotaConsume(ctx context.Context, key string, limit int64, n int64) (int64, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.DailyQuotaConsume") + defer span.End() + + if limit <= 0 || n <= 0 { + return -1, nil + } + val, err := dailyQuotaConsumeScript.Run(ctx, global.RedisEngine, []string{key}, limit, n, int(quotaDailyTTL.Seconds())).Int64() + if err != nil { + span.RecordError(err) + return 0, err + } + return val, nil +} + +// DailyQuotaRefund 回退当日计数(保证不小于 0)。失败仅记录,调用方按 best-effort 处理。 +func DailyQuotaRefund(ctx context.Context, key string, n int64) error { + ctx, span := global.Tracer.Start(ctx, "redisdao.DailyQuotaRefund") + defer span.End() + + if n <= 0 { + return nil + } + if _, err := dailyQuotaRefundScript.Run(ctx, global.RedisEngine, []string{key}, n).Int64(); err != nil { + span.RecordError(err) + return err + } + return nil +} + +// DailyQuotaValue 读取当日计数,key 不存在时返回 0。 +func DailyQuotaValue(ctx context.Context, key string) (int64, error) { + return quotaDailyValue(ctx, key) +} + diff --git a/internal/redisdao/user.go b/internal/redisdao/user.go new file mode 100644 index 0000000..84fb86e --- /dev/null +++ b/internal/redisdao/user.go @@ -0,0 +1,71 @@ +package redisdao + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" + "github.com/redis/go-redis/v9" +) + +// userInfoTTL 用户鉴权信息缓存默认过期时间 +const userInfoTTL = 24 * time.Hour + +// UserInfoSet 缓存用户鉴权核心信息(含 Status,供中间件鉴权时快速读取) +func UserInfoSet(ctx context.Context, user *mysqlmodel.User, ttl time.Duration) error { + ctx, span := global.Tracer.Start(ctx, "redisdao.UserInfoSet") + defer span.End() + + if ttl <= 0 { + ttl = userInfoTTL + } + data, err := json.Marshal(user) + if err != nil { + span.RecordError(err) + return err + } + key := fmt.Sprintf(redismodel.KeyUserInfo, user.ID) + if err := global.RedisEngine.Set(ctx, key, data, ttl).Err(); err != nil { + span.RecordError(err) + return err + } + return nil +} + +// UserInfoDel 删除用户缓存(用户资料变更/注销时失效缓存) +func UserInfoDel(ctx context.Context, userID int64) error { + ctx, span := global.Tracer.Start(ctx, "redisdao.UserInfoDel") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyUserInfo, userID) + if err := global.RedisEngine.Del(ctx, key).Err(); err != nil { + span.RecordError(err) + return err + } + return nil +} + +// UserInfoGet 读取缓存中的用户鉴权核心信息,未命中返回 redis.Nil +func UserInfoGet(ctx context.Context, userID int64) (*mysqlmodel.User, error) { + ctx, span := global.Tracer.Start(ctx, "redisdao.UserInfoGet") + defer span.End() + + key := fmt.Sprintf(redismodel.KeyUserInfo, userID) + data, err := global.RedisEngine.Get(ctx, key).Bytes() + if err != nil { + if err != redis.Nil { + span.RecordError(err) + } + return nil, err + } + var user mysqlmodel.User + if err := json.Unmarshal(data, &user); err != nil { + span.RecordError(err) + return nil, err + } + return &user, nil +} diff --git a/internal/redismodel/auth.go b/internal/redismodel/auth.go new file mode 100644 index 0000000..6eaa1f2 --- /dev/null +++ b/internal/redismodel/auth.go @@ -0,0 +1,9 @@ +package redismodel + +// 认证模块:token 与用户会话的 Redis key。 +const ( + KeyAuthAccess = RedisPrefix + "auth:access:%s" // access_token -> user_id(String) + KeyAuthRefresh = RedisPrefix + "auth:refresh:%s" // refresh_token -> user_id(String) + KeyAuthUserTokens = RedisPrefix + "auth:user:tokens:%d" // user_id -> token 集合(Set,支持多端登录) + KeyAuthSessionKey = RedisPrefix + "auth:session_key:%d" // user_id -> session_key(String,虚拟支付 signature 用) +) diff --git a/internal/redismodel/closet.go b/internal/redismodel/closet.go new file mode 100644 index 0000000..f3e9c8c --- /dev/null +++ b/internal/redismodel/closet.go @@ -0,0 +1,4 @@ +package redismodel + +// 衣柜分享模块:分享访问计数。 +const KeyClosetShareView = RedisPrefix + "closet:share:view:%s" // share_uuid,衣柜分享访问计数(INCR) diff --git a/internal/redismodel/collect.go b/internal/redismodel/collect.go new file mode 100644 index 0000000..3da1732 --- /dev/null +++ b/internal/redismodel/collect.go @@ -0,0 +1,4 @@ +package redismodel + +// 收藏模块:按业务类型 + 目标 ID 分桶的收藏集合。 +const KeyCollectSet = RedisPrefix + "collect:set:%d:%d" // collect_type:target_id,成员为 user_id(Set,1=社区穿搭) diff --git a/internal/redismodel/horoscope.go b/internal/redismodel/horoscope.go new file mode 100644 index 0000000..024feeb --- /dev/null +++ b/internal/redismodel/horoscope.go @@ -0,0 +1,4 @@ +package redismodel + +// 星座模块:按星座 + 日期缓存的运势内容。 +const KeyHoroscope = RedisPrefix + "horoscope:%s:%d" // zodiac:stat_date(YYYYMMDD),星座运势缓存(String/JSON) diff --git a/internal/redismodel/keys.go b/internal/redismodel/keys.go new file mode 100644 index 0000000..0324670 --- /dev/null +++ b/internal/redismodel/keys.go @@ -0,0 +1,41 @@ +package redismodel + +import ( + "context" + "fmt" + "log/slog" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting" + "github.com/redis/go-redis/v9" +) + +// RedisPrefix 是所有 Redis key 的全局命名空间前缀,避免多服务共享 Redis 实例时发生 key 冲突。 +const RedisPrefix = "ztcloset:" + +// NewRedisClient 根据配置创建 Redis 客户端并验证连接。 +func NewRedisEngine(s *setting.RedisSettingS) (*redis.Client, error) { + client := redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%d", s.Host, s.Port), + Password: s.Password, + DB: s.DB, + PoolSize: s.PoolSize, + MinIdleConns: s.MinIdleConns, + MaxIdleConns: s.MaxIdleConns, + MinRetryBackoff: time.Duration(s.MinRetryBackoff) * time.Millisecond, + MaxRetryBackoff: time.Duration(s.MaxRetryBackoff) * time.Millisecond, + MaxRetries: s.MaxRetries, + // 连接/读写/池超时配置单位为秒(与 config.yaml 注释一致)。 + DialTimeout: time.Duration(s.DialTimeout) * time.Second, + ReadTimeout: time.Duration(s.ReadTimeout) * time.Second, + WriteTimeout: time.Duration(s.WriteTimeout) * time.Second, + PoolTimeout: time.Duration(s.PoolTimeout) * time.Second, + }) + + if err := client.Ping(context.Background()).Err(); err != nil { + return nil, fmt.Errorf("redis ping failed: %w", err) + } + + slog.Info("redis connected", "addr", fmt.Sprintf("%s:%d", s.Host, s.Port)) + return client, nil +} diff --git a/internal/redismodel/like.go b/internal/redismodel/like.go new file mode 100644 index 0000000..12f0e1d --- /dev/null +++ b/internal/redismodel/like.go @@ -0,0 +1,4 @@ +package redismodel + +// 点赞模块:按业务类型 + 目标 ID 分桶的点赞集合。 +const KeyLikeSet = RedisPrefix + "like:set:%d:%d" // like_type:target_id,成员为 user_id(Set,1=社区穿搭) diff --git a/internal/redismodel/lock.go b/internal/redismodel/lock.go new file mode 100644 index 0000000..292434b --- /dev/null +++ b/internal/redismodel/lock.go @@ -0,0 +1,13 @@ +package redismodel + +// 分布式锁与幂等模块:对齐 mysqlmodel 的 Order、AIRecommend、AIHoroscope、审核等业务。 +const ( + KeyLockOrderCreate = RedisPrefix + "lock:order:create:%d" // order_id,下单防重 + KeyLockPayCallback = RedisPrefix + "lock:pay:callback:%s" // order_no,支付回调幂等 + KeyLockRefundCallback = RedisPrefix + "lock:refund:callback:%s" // refund_no,退款回调幂等 + KeyLockAIRecommend = RedisPrefix + "lock:ai:recommend:%d" // user_id,AI推荐防并发 + KeyLockAIHoroscope = RedisPrefix + "lock:ai:horoscope:%d:%s" // user_id:zodiac,AI星座防并发 + KeyLockClosetAudit = RedisPrefix + "lock:audit:closet:%d" // target_id,衣柜分享审核防重 + KeyLockOutfitAudit = RedisPrefix + "lock:audit:outfit:%d" // target_id,穿搭审核防重 + KeyLockOutfitPublic = RedisPrefix + "lock:outfit:public:%s" // outfit_uuid,穿搭公开/取消公开串行化 +) diff --git a/internal/redismodel/notification.go b/internal/redismodel/notification.go new file mode 100644 index 0000000..1e22cce --- /dev/null +++ b/internal/redismodel/notification.go @@ -0,0 +1,4 @@ +package redismodel + +// 通知模块:用户未读消息计数。 +const KeyNotificationUnread = RedisPrefix + "notification:unread:%d" // user_id,未读消息数(INCR/DECR) diff --git a/internal/redismodel/outfit.go b/internal/redismodel/outfit.go new file mode 100644 index 0000000..427b26c --- /dev/null +++ b/internal/redismodel/outfit.go @@ -0,0 +1,4 @@ +package redismodel + +// 穿搭模块:公开穿搭浏览计数。 +const KeyOutfitView = RedisPrefix + "outfit:view:%d" // outfit_id,公开穿搭浏览计数(INCR) diff --git a/internal/redismodel/quota.go b/internal/redismodel/quota.go new file mode 100644 index 0000000..490d499 --- /dev/null +++ b/internal/redismodel/quota.go @@ -0,0 +1,9 @@ +package redismodel + +// 配额模块:对齐 mysqlmodel 的 Quota(总配额)与 UserDailyUsage(每日使用计数)。 +// 说明:每日使用计数使用 Redis INCR 做当日限流统计,权威值仍以 MySQL 为准。 +const ( + KeyQuotaDailyAI = RedisPrefix + "quota:daily:ai:%d:%d" // user_id:date(YYYYMMDD),当日AI推荐次数(INCR) + KeyQuotaDailyOutfitShare = RedisPrefix + "quota:daily:outfit_share:%d:%d" // user_id:date(YYYYMMDD),当日社区分享次数(搭配分享,INCR) + KeyQuotaDailyClosetShare = RedisPrefix + "quota:daily:closet_share:%d:%d" // user_id:date(YYYYMMDD),当日衣柜分享次数(INCR) +) diff --git a/internal/redismodel/user.go b/internal/redismodel/user.go new file mode 100644 index 0000000..512f777 --- /dev/null +++ b/internal/redismodel/user.go @@ -0,0 +1,4 @@ +package redismodel + +// 用户模块:用户鉴权核心信息缓存。 +const KeyUserInfo = RedisPrefix + "user:info:%d" // user_id -> 用户鉴权核心信息(String/JSON,含 Status) diff --git a/internal/routers/api/v1/adreward.go b/internal/routers/api/v1/adreward.go new file mode 100644 index 0000000..b3e5f79 --- /dev/null +++ b/internal/routers/api/v1/adreward.go @@ -0,0 +1,41 @@ +package v1 + +import ( + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "github.com/gin-gonic/gin" +) + +// AdRewardHandler 广告激励额度接口。 +type AdRewardHandler struct { + svc *service.AdRewardService +} + +// NewAdRewardHandler 创建广告激励 handler。 +func NewAdRewardHandler() *AdRewardHandler { + return &AdRewardHandler{svc: service.NewAdRewardService()} +} + +// Grant 发放广告激励额度(当天有效,幂等防重复领取)。 +func (h *AdRewardHandler) Grant(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.AdRewardGrantRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.Grant(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} diff --git a/internal/routers/api/v1/airecommend.go b/internal/routers/api/v1/airecommend.go new file mode 100644 index 0000000..9806464 --- /dev/null +++ b/internal/routers/api/v1/airecommend.go @@ -0,0 +1,61 @@ +package v1 + +import ( + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "github.com/gin-gonic/gin" +) + +// AIRecommendHandler AI 穿搭推荐相关接口。 +type AIRecommendHandler struct { + svc *service.AIRecommendService +} + +// NewAIRecommendHandler 创建 AI 推荐 handler。 +func NewAIRecommendHandler() *AIRecommendHandler { + return &AIRecommendHandler{svc: service.NewAIRecommendService()} +} + +// Create 发起 AI 穿搭推荐。 +func (h *AIRecommendHandler) Create(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.AIRecommendCreateRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.Create(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// Save 保存 AI 推荐穿搭到我的搭配。 +func (h *AIRecommendHandler) Save(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.AIRecommendSaveRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + if e := h.svc.Save(c.Request.Context(), userID, &request); e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(nil) +} diff --git a/internal/routers/api/v1/closet.go b/internal/routers/api/v1/closet.go new file mode 100644 index 0000000..c1cfec2 --- /dev/null +++ b/internal/routers/api/v1/closet.go @@ -0,0 +1,125 @@ +package v1 + +import ( + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "github.com/gin-gonic/gin" +) + +// ClosetShareHandler 衣柜分享相关接口。 +type ClosetShareHandler struct { + svc *service.ClosetShareService +} + +// NewClosetShareHandler 创建衣柜分享 handler。 +func NewClosetShareHandler() *ClosetShareHandler { + return &ClosetShareHandler{svc: service.NewClosetShareService()} +} + +// Create 创建衣柜分享。 +func (h *ClosetShareHandler) Create(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.ClosetShareCreateRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.Create(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// List 分页查询我的分享。 +func (h *ClosetShareHandler) List(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.ClosetShareListRequest + if err := c.ShouldBindQuery(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.List(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// ReceivedList 分页查询分享给我的分享。 +func (h *ClosetShareHandler) ReceivedList(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.ClosetShareListRequest + if err := c.ShouldBindQuery(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.ListReceived(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// Detail 查看分享详情(uuid 来自路径参数)。 +func (h *ClosetShareHandler) Detail(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + shareUUID := c.Param("uuid") + if shareUUID == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.Detail(c.Request.Context(), userID, shareUUID) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// Cancel 取消分享(uuid 来自路径参数)。 +func (h *ClosetShareHandler) Cancel(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + request := dto.ClosetShareCancelRequest{ShareUUID: c.Param("uuid")} + if request.ShareUUID == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.Cancel(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} diff --git a/internal/routers/api/v1/clothes.go b/internal/routers/api/v1/clothes.go new file mode 100644 index 0000000..3f690b8 --- /dev/null +++ b/internal/routers/api/v1/clothes.go @@ -0,0 +1,158 @@ +package v1 + +import ( + "net/http" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "github.com/gin-gonic/gin" +) + +// ClothesHandler 衣物相关接口。 +type ClothesHandler struct { + svc *service.ClothesService +} + +// NewClothesHandler 创建衣物 handler。 +func NewClothesHandler() *ClothesHandler { + return &ClothesHandler{svc: service.NewClothesService()} +} + +// Upload 上传衣物图片(multipart,字段名 file)。 +func (h *ClothesHandler) Upload(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + // 解析 multipart 前限制请求体大小(MaxSizeMB + 1MB 的 multipart 边界开销), + // 防止超大 body 在 FormFile 解析时耗尽内存/磁盘。 + maxBytes := int64(global.UploadSetting.MaxSizeMB)*1024*1024 + 1024*1024 + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes) + fileHeader, err := c.FormFile("file") + if err != nil { + response.ToErrorResponse(errcode.UploadFileEmpty.WithDetails(err.Error())) + return + } + out, e := h.svc.Upload(c.Request.Context(), userID, fileHeader) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// List 分页查询当前用户衣物。 +func (h *ClothesHandler) List(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.ClothesListRequest + if err := c.ShouldBindQuery(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.List(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// Get 获取衣物详情(uuid 来自路径参数)。 +func (h *ClothesHandler) Get(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + request := dto.ClothesGetByUUIDRequest{ClothesUUID: c.Param("uuid")} + if request.ClothesUUID == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.GetByUUID(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// Status 查询衣物图片处理状态(uuid 来自路径参数)。 +func (h *ClothesHandler) Status(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + request := dto.ClothesProcessStatusGetByUUIDRequest{ClothesUUID: c.Param("uuid")} + if request.ClothesUUID == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.ProcessStatusGetByUUID(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// Update 修改衣物属性(uuid 来自路径参数)。 +func (h *ClothesHandler) Update(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.ClothesUpdateByUUIDRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + request.ClothesUUID = c.Param("uuid") + if request.ClothesUUID == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.UpdateByUUID(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// Delete 删除衣物(uuid 来自路径参数)。 +func (h *ClothesHandler) Delete(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + request := dto.ClothesDeleteByUUIDRequest{ClothesUUID: c.Param("uuid")} + if request.ClothesUUID == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.DeleteByUUID(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} diff --git a/internal/routers/api/v1/collect.go b/internal/routers/api/v1/collect.go new file mode 100644 index 0000000..287f6ea --- /dev/null +++ b/internal/routers/api/v1/collect.go @@ -0,0 +1,83 @@ +package v1 + +import ( + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "github.com/gin-gonic/gin" +) + +// CollectHandler 收藏相关接口。 +type CollectHandler struct { + svc *service.CollectService +} + +// NewCollectHandler 创建收藏 handler。 +func NewCollectHandler() *CollectHandler { + return &CollectHandler{svc: service.NewCollectService()} +} + +// Collect 收藏/取消收藏社区穿搭。 +func (h *CollectHandler) Collect(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.CollectRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.Collect(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// List 分页查询我收藏的穿搭。 +func (h *CollectHandler) List(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.CollectListRequest + if err := c.ShouldBindQuery(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.List(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// GetByTarget 查询穿搭收藏状态与数量(targetUUID 来自路径参数)。 +func (h *CollectHandler) GetByTarget(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + request := dto.CollectGetByTargetRequest{TargetUUID: c.Param("targetUUID")} + if request.TargetUUID == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.GetByTarget(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} diff --git a/internal/routers/api/v1/goods.go b/internal/routers/api/v1/goods.go new file mode 100644 index 0000000..e2deb8e --- /dev/null +++ b/internal/routers/api/v1/goods.go @@ -0,0 +1,51 @@ +package v1 + +import ( + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "github.com/gin-gonic/gin" +) + +// GoodsHandler 商品相关接口(公开)。 +type GoodsHandler struct { + svc *service.GoodsService +} + +// NewGoodsHandler 创建商品 handler。 +func NewGoodsHandler() *GoodsHandler { + return &GoodsHandler{svc: service.NewGoodsService()} +} + +// GetByCode 按商品编码查询商品(编码来自路径参数)。 +func (h *GoodsHandler) GetByCode(c *gin.Context) { + response := app.NewResponse(c) + request := dto.GoodsGetByCodeRequest{GoodsCode: c.Param("code")} + if request.GoodsCode == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.GetByCode(c.Request.Context(), &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// List 分页查询上架商品。 +func (h *GoodsHandler) List(c *gin.Context) { + response := app.NewResponse(c) + var request dto.GoodsListRequest + if err := c.ShouldBindQuery(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.List(c.Request.Context(), &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} diff --git a/internal/routers/api/v1/horoscope.go b/internal/routers/api/v1/horoscope.go new file mode 100644 index 0000000..dd04b88 --- /dev/null +++ b/internal/routers/api/v1/horoscope.go @@ -0,0 +1,35 @@ +package v1 + +import ( + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "github.com/gin-gonic/gin" +) + +// HoroscopeHandler 今日运势接口。 +type HoroscopeHandler struct { + svc *service.HoroscopeService +} + +// NewHoroscopeHandler 创建运势 handler。 +func NewHoroscopeHandler() *HoroscopeHandler { + return &HoroscopeHandler{svc: service.NewHoroscopeService()} +} + +// Today 获取当前用户今日运势。 +func (h *HoroscopeHandler) Today(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + out, e := h.svc.GetToday(c.Request.Context(), userID) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} diff --git a/internal/routers/api/v1/like.go b/internal/routers/api/v1/like.go new file mode 100644 index 0000000..9e8e6a0 --- /dev/null +++ b/internal/routers/api/v1/like.go @@ -0,0 +1,62 @@ +package v1 + +import ( + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "github.com/gin-gonic/gin" +) + +// LikeHandler 点赞相关接口。 +type LikeHandler struct { + svc *service.LikeService +} + +// NewLikeHandler 创建点赞 handler。 +func NewLikeHandler() *LikeHandler { + return &LikeHandler{svc: service.NewLikeService()} +} + +// Like 点赞/取消点赞社区穿搭。 +func (h *LikeHandler) Like(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.LikeCreateRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.LikeCreate(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// GetByTarget 查询穿搭点赞状态与数量(targetUUID 来自路径参数)。 +func (h *LikeHandler) GetByTarget(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + request := dto.LikeGetByTargetIDRequest{TargetUUID: c.Param("targetUUID")} + if request.TargetUUID == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.GetByTargetID(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} diff --git a/internal/routers/api/v1/notification.go b/internal/routers/api/v1/notification.go new file mode 100644 index 0000000..982bad9 --- /dev/null +++ b/internal/routers/api/v1/notification.go @@ -0,0 +1,99 @@ +package v1 + +import ( + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "github.com/gin-gonic/gin" +) + +// NotificationHandler 通知相关接口。 +type NotificationHandler struct { + svc *service.NotificationService +} + +// NewNotificationHandler 创建通知 handler。 +func NewNotificationHandler() *NotificationHandler { + return &NotificationHandler{svc: service.NewNotificationService()} +} + +// List 分页查询当前用户通知。 +func (h *NotificationHandler) List(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.NotificationListRequest + if err := c.ShouldBindQuery(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.List(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// Unread 获取当前用户未读数。 +func (h *NotificationHandler) Unread(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + out, e := h.svc.Unread(c.Request.Context(), userID) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// Get 获取通知详情(uuid 来自路径参数)。 +func (h *NotificationHandler) Get(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + request := dto.NotificationGetByUUIDRequest{NotificationUUID: c.Param("uuid")} + if request.NotificationUUID == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.GetByUUID(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// Read 标记通知已读(uuid 来自路径参数)。 +func (h *NotificationHandler) Read(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + request := dto.NotificationReadRequest{NotificationUUID: c.Param("uuid")} + if request.NotificationUUID == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.Read(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} diff --git a/internal/routers/api/v1/order.go b/internal/routers/api/v1/order.go new file mode 100644 index 0000000..d411e7d --- /dev/null +++ b/internal/routers/api/v1/order.go @@ -0,0 +1,181 @@ +package v1 + +import ( + "io" + "net/http" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat" + "github.com/gin-gonic/gin" +) + +// OrderHandler 订单与退款相关接口。 +type OrderHandler struct { + svc *service.OrderService +} + +// NewOrderHandler 创建订单 handler。 +func NewOrderHandler() *OrderHandler { + return &OrderHandler{svc: service.NewOrderService()} +} + +// Create 创建订单并发起微信下单。 +func (h *OrderHandler) Create(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.OrderCreateRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.Create(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// VirtualCreate 创建虚拟支付订单(小程序虚拟支付,覆盖 iOS/Android 全终端)。 +func (h *OrderHandler) VirtualCreate(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.VirtualOrderCreateRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.VirtualOrderCreate(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// List 分页查询我的订单。 +func (h *OrderHandler) List(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.OrderListRequest + if err := c.ShouldBindQuery(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.List(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// Cancel 取消订单(orderNo 来自路径参数)。 +func (h *OrderHandler) Cancel(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + request := dto.OrderCancelRequest{OrderNo: c.Param("orderNo")} + if request.OrderNo == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.Cancel(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// RefundApply 申请退款。 +func (h *OrderHandler) RefundApply(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.RefundApplyRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.RefundApply(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// PayNotify 微信支付结果回调(公开,按微信规范返回受理结果)。 +func (h *OrderHandler) PayNotify(c *gin.Context) { + if e := h.svc.PayNotify(c.Request.Context(), c.Request); e != nil { + wechatCallbackFail(c, e.Msg()) + return + } + wechatCallbackSuccess(c) +} + +// RefundNotify 微信退款结果回调(公开,按微信规范返回受理结果)。 +func (h *OrderHandler) RefundNotify(c *gin.Context) { + if e := h.svc.RefundNotify(c.Request.Context(), c.Request); e != nil { + wechatCallbackFail(c, e.Msg()) + return + } + wechatCallbackSuccess(c) +} + +// XPayVerify 虚拟支付消息推送 URL 配置验证(GET):校验签名并原样回显 echostr。 +func (h *OrderHandler) XPayVerify(c *gin.Context) { + timestamp := c.Query("timestamp") + nonce := c.Query("nonce") + signature := c.Query("signature") + echostr := c.Query("echostr") + if wechat.VerifyNotifyURL(timestamp, nonce, signature) { + c.String(http.StatusOK, echostr) + return + } + c.String(http.StatusUnauthorized, "invalid signature") +} + +// XPayNotify 虚拟支付消息推送回调(POST,公开,按虚拟支付规范返回 ErrCode 应答)。 +func (h *OrderHandler) XPayNotify(c *gin.Context) { + raw, err := io.ReadAll(c.Request.Body) + if err != nil { + c.Data(http.StatusOK, "application/json; charset=utf-8", wechat.BuildNotifyResponse(false)) + return + } + resp := h.svc.XPayNotify(c.Request.Context(), raw) + c.Data(http.StatusOK, "application/json; charset=utf-8", resp) +} + +// wechatCallbackSuccess 按微信规范返回受理成功,避免微信重复回调。 +func wechatCallbackSuccess(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "成功"}) +} + +// wechatCallbackFail 按微信规范返回受理失败,微信将按策略重试。 +func wechatCallbackFail(c *gin.Context, message string) { + c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": message}) +} diff --git a/internal/routers/api/v1/outfit.go b/internal/routers/api/v1/outfit.go new file mode 100644 index 0000000..efc95fe --- /dev/null +++ b/internal/routers/api/v1/outfit.go @@ -0,0 +1,156 @@ +package v1 + +import ( + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "github.com/gin-gonic/gin" +) + +// OutfitHandler 穿搭相关接口。 +type OutfitHandler struct { + svc *service.OutfitService +} + +// NewOutfitHandler 创建穿搭 handler。 +func NewOutfitHandler() *OutfitHandler { + return &OutfitHandler{svc: service.NewOutfitService()} +} + +// Create 创建穿搭。 +func (h *OutfitHandler) Create(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.OutfitCreateRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.Create(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// List 分页查询我的穿搭。 +func (h *OutfitHandler) List(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.OutfitListRequest + if err := c.ShouldBindQuery(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.List(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// Get 获取穿搭详情(uuid 来自路径参数)。 +func (h *OutfitHandler) Get(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + request := dto.OutfitGetByUUIDRequest{OutfitUUID: c.Param("uuid")} + if request.OutfitUUID == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.GetByUUID(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// Update 修改穿搭(uuid 来自路径参数)。 +func (h *OutfitHandler) Update(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.OutfitUpdateByUUIDRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + request.OutfitUUID = c.Param("uuid") + if request.OutfitUUID == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.UpdateByUUID(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// Delete 删除穿搭(uuid 来自路径参数)。 +func (h *OutfitHandler) Delete(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + request := dto.OutfitDeleteRequest{OutfitUUID: c.Param("uuid")} + if request.OutfitUUID == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.Delete(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// Public 设置穿搭公开状态(uuid 来自路径参数)。 +func (h *OutfitHandler) Public(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.OutfitPublicRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + request.OutfitUUID = c.Param("uuid") + if request.OutfitUUID == "" { + response.ToErrorResponse(errcode.InvalidParams) + return + } + out, e := h.svc.Public(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} diff --git a/internal/routers/api/v1/quota.go b/internal/routers/api/v1/quota.go new file mode 100644 index 0000000..e4969d1 --- /dev/null +++ b/internal/routers/api/v1/quota.go @@ -0,0 +1,57 @@ +package v1 + +import ( + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "github.com/gin-gonic/gin" +) + +// QuotaHandler 额度相关接口。 +type QuotaHandler struct { + svc *service.QuotaService +} + +// NewQuotaHandler 创建额度 handler。 +func NewQuotaHandler() *QuotaHandler { + return &QuotaHandler{svc: service.NewQuotaService()} +} + +// Info 获取当前用户额度概览。 +func (h *QuotaHandler) Info(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + out, e := h.svc.InfoGetBySelf(c.Request.Context(), userID) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// FlowList 分页查询当前用户额度流水。 +func (h *QuotaHandler) FlowList(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.QuotaFlowListRequest + if err := c.ShouldBindQuery(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.FlowList(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} diff --git a/internal/routers/api/v1/user.go b/internal/routers/api/v1/user.go new file mode 100644 index 0000000..a262386 --- /dev/null +++ b/internal/routers/api/v1/user.go @@ -0,0 +1,119 @@ +// Package v1 提供 /api/v1 版本的 HTTP handler,负责请求绑定、鉴权透传与统一响应, +// 业务逻辑与归属校验均在 internal/service 层完成。 +package v1 + +import ( + "net/http" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/app" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "github.com/gin-gonic/gin" +) + +// UserHandler 用户相关接口。 +type UserHandler struct { + svc *service.UserService +} + +// NewUserHandler 创建用户 handler。 +func NewUserHandler() *UserHandler { + return &UserHandler{svc: service.NewUserService()} +} + +// WxLogin 微信登录(公开)。 +func (h *UserHandler) WxLogin(c *gin.Context) { + response := app.NewResponse(c) + var request dto.UserWxLoginRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.WxLogin(c.Request.Context(), &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// RefreshToken 刷新访问令牌(公开)。 +func (h *UserHandler) RefreshToken(c *gin.Context) { + response := app.NewResponse(c) + var request dto.UserRefreshTokenRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.RefreshToken(c.Request.Context(), &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// InfoGetBySelf 获取当前用户信息。 +func (h *UserHandler) InfoGetBySelf(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + out, e := h.svc.InfoGetBySelf(c.Request.Context(), userID) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// ProfileUpdate 修改当前用户资料。 +func (h *UserHandler) ProfileUpdate(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + var request dto.UserProfileUpdateBySelfRequest + if err := c.ShouldBindJSON(&request); err != nil { + response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error())) + return + } + out, e := h.svc.ProfileUpdate(c.Request.Context(), userID, &request) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} + +// AvatarUpload 上传用户头像(multipart,字段名 file)。 +func (h *UserHandler) AvatarUpload(c *gin.Context) { + response := app.NewResponse(c) + userID, ok := middleware.GetUserID(c.Request.Context()) + if !ok { + response.ToErrorResponse(errcode.Unauthorized) + return + } + // 解析 multipart 前限制请求体大小(MaxSizeMB + 1MB 的 multipart 边界开销), + // 防止超大 body 在 FormFile 解析时耗尽内存/磁盘。 + maxBytes := int64(global.UploadSetting.MaxSizeMB)*1024*1024 + 1024*1024 + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes) + fileHeader, err := c.FormFile("file") + if err != nil { + response.ToErrorResponse(errcode.UploadFileEmpty.WithDetails(err.Error())) + return + } + out, e := h.svc.AvatarUpload(c.Request.Context(), userID, fileHeader) + if e != nil { + response.ToErrorResponse(e) + return + } + response.ToResponse(out) +} diff --git a/internal/routers/router.go b/internal/routers/router.go new file mode 100644 index 0000000..e53fa5c --- /dev/null +++ b/internal/routers/router.go @@ -0,0 +1,133 @@ +package routers + +import ( + "net/http" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/middleware" + v1 "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/routers/api/v1" + "github.com/gin-gonic/gin" +) + +// NewRouter 构建 gin 引擎:注册中间件链与 /api/v1 路由。 +func NewRouter() *gin.Engine { + r := gin.New() + + // 中间件链:Recovery -> RequestID -> OTel -> Slog -> CORS + r.Use( + gin.Recovery(), + middleware.RequestID(), + middleware.GinOTelMiddleware(), + middleware.GinSlogMiddleware(), + middleware.CORS(), + ) + + // 健康检查 + r.GET("/ping", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "pong"}) + }) + + // handler 实例 + userHandler := v1.NewUserHandler() + quotaHandler := v1.NewQuotaHandler() + horoscopeHandler := v1.NewHoroscopeHandler() + clothesHandler := v1.NewClothesHandler() + outfitHandler := v1.NewOutfitHandler() + closetHandler := v1.NewClosetShareHandler() + collectHandler := v1.NewCollectHandler() + likeHandler := v1.NewLikeHandler() + notificationHandler := v1.NewNotificationHandler() + goodsHandler := v1.NewGoodsHandler() + orderHandler := v1.NewOrderHandler() + aiHandler := v1.NewAIRecommendHandler() + adRewardHandler := v1.NewAdRewardHandler() + + apiV1 := r.Group("/api/v1") + + // ===== 公开路由(无需登录)===== + { + apiV1.POST("/user/wxlogin", userHandler.WxLogin) + apiV1.POST("/user/refresh-token", userHandler.RefreshToken) + + apiV1.GET("/goods", goodsHandler.List) + apiV1.GET("/goods/:code", goodsHandler.GetByCode) + + // 微信支付/退款回调 + apiV1.POST("/order/pay/notify", orderHandler.PayNotify) + apiV1.POST("/order/refund/notify", orderHandler.RefundNotify) + + // 虚拟支付消息推送(GET 为 URL 配置验证,POST 为业务回调) + apiV1.GET("/xpay/notify", orderHandler.XPayVerify) + apiV1.POST("/xpay/notify", orderHandler.XPayNotify) + } + + // ===== 鉴权路由(需登录)===== + auth := apiV1.Group("") + auth.Use(middleware.JWTAuth()) + { + // 用户 + auth.GET("/user/info", userHandler.InfoGetBySelf) + auth.PUT("/user/profile", userHandler.ProfileUpdate) + auth.POST("/user/avatar/upload", userHandler.AvatarUpload) + + // 额度 + auth.GET("/quota/info", quotaHandler.Info) + auth.GET("/quota/flow", quotaHandler.FlowList) + + // 运势 + auth.GET("/horoscope/today", horoscopeHandler.Today) + + // 衣物 + auth.POST("/clothes/upload", clothesHandler.Upload) + auth.GET("/clothes", clothesHandler.List) + auth.GET("/clothes/:uuid", clothesHandler.Get) + auth.GET("/clothes/:uuid/status", clothesHandler.Status) + auth.PUT("/clothes/:uuid", clothesHandler.Update) + auth.DELETE("/clothes/:uuid", clothesHandler.Delete) + + // 穿搭 + auth.POST("/outfit", outfitHandler.Create) + auth.GET("/outfit", outfitHandler.List) + auth.GET("/outfit/:uuid", outfitHandler.Get) + auth.PUT("/outfit/:uuid", outfitHandler.Update) + auth.DELETE("/outfit/:uuid", outfitHandler.Delete) + auth.PUT("/outfit/:uuid/public", outfitHandler.Public) + + // 衣柜分享 + auth.POST("/closet-share", closetHandler.Create) + auth.GET("/closet-share", closetHandler.List) + auth.GET("/closet-share/received", closetHandler.ReceivedList) + auth.GET("/closet-share/:uuid", closetHandler.Detail) + auth.POST("/closet-share/:uuid/cancel", closetHandler.Cancel) + + // 收藏 + auth.POST("/collect", collectHandler.Collect) + auth.GET("/collect", collectHandler.List) + auth.GET("/collect/:targetUUID", collectHandler.GetByTarget) + + // 点赞 + auth.POST("/like", likeHandler.Like) + auth.GET("/like/:targetUUID", likeHandler.GetByTarget) + + // 通知 + auth.GET("/notification", notificationHandler.List) + auth.GET("/notification/unread", notificationHandler.Unread) + auth.GET("/notification/:uuid", notificationHandler.Get) + auth.POST("/notification/:uuid/read", notificationHandler.Read) + + // 订单 + auth.POST("/order", orderHandler.Create) + auth.POST("/order/virtual", orderHandler.VirtualCreate) + auth.GET("/order", orderHandler.List) + auth.POST("/order/:orderNo/cancel", orderHandler.Cancel) + auth.POST("/order/refund", orderHandler.RefundApply) + + // 广告激励额度 + auth.POST("/ad-reward/grant", adRewardHandler.Grant) + + // AI 推荐 + auth.POST("/ai-recommend", aiHandler.Create) + auth.POST("/ai-recommend/save", aiHandler.Save) + } + + return r +} diff --git a/internal/service/adreward.go b/internal/service/adreward.go new file mode 100644 index 0000000..91aa090 --- /dev/null +++ b/internal/service/adreward.go @@ -0,0 +1,113 @@ +package service + +import ( + "context" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" +) + +// AdRewardService 广告激励额度服务。 +type AdRewardService struct{} + +// NewAdRewardService 创建广告激励额度服务。 +func NewAdRewardService() *AdRewardService { + return &AdRewardService{} +} + +// Grant 发放广告激励额度:校验开关后按服务端配置的固定金额创建当天有效凭证。 +// ref_id+quota_type 唯一索引兜底幂等,重复领取返回 AdRewardClaimed。 +func (s *AdRewardService) Grant(ctx context.Context, userID int64, request *dto.AdRewardGrantRequest) (*dto.AdRewardGrantResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.AdRewardGrant") + defer span.End() + + quotaType, ok := parseAdRewardType(request.RewardType) + if !ok { + return nil, errcode.AdRewardInvalid + } + + // 开关校验:广告激励未开启时拒绝发放。 + if global.AdRewardSetting == nil || !global.AdRewardSetting.Enable { + return nil, errcode.AdRewardDisabled + } + + // 服务端定价:发放金额由配置固定,前端仅传类型与广告记录 ID,杜绝刷额度。 + amount := adRewardAmount(quotaType) + if amount <= 0 { + return nil, errcode.AdRewardInvalid.WithDetails("该激励类型未配置发放数量") + } + + grant := &mysqlmodel.QuotaGrant{ + UserID: userID, + QuotaType: quotaType, + Amount: amount, + Used: 0, + Source: QuotaGrantSourceAdReward, + ExpireTime: todayEndUnix(), // 当天 23:59:59 到期 + RefID: request.RefID, + } + if err := mysqldao.NewQuotaGrantDao().QuotaGrantCreate(ctx, grant); err != nil { + if isDuplicateKey(err) { + return nil, errcode.AdRewardClaimed + } + return nil, errcode.DatabaseError + } + + return &dto.AdRewardGrantResponse{ + RefID: request.RefID, + QuotaType: quotaType, + Amount: amount, + }, nil +} + +// adRewardAmount 返回指定额度类型的广告激励固定金额(服务端配置)。 +// 未配置(指针为 nil)或配置为非正数时返回 0,表示不发放该类型激励。 +func adRewardAmount(quotaType int8) int64 { + cfg := global.AdRewardSetting + if cfg == nil { + return 0 + } + var p *int64 + switch quotaType { + case QuotaTypeCos: + p = cfg.Cos + case QuotaTypeAI: + p = cfg.AI + case QuotaTypeOutfitShare: + p = cfg.OutfitShare + case QuotaTypeClosetShare: + p = cfg.ClosetShare + default: + return 0 + } + if p == nil || *p <= 0 { + return 0 + } + return *p +} + +// parseAdRewardType 将 reward_type 字符串映射到额度类型。 +func parseAdRewardType(rewardType string) (int8, bool) { + switch rewardType { + case "cos": + return QuotaTypeCos, true + case "ai": + return QuotaTypeAI, true + case "outfit_share": + return QuotaTypeOutfitShare, true + case "closet_share": + return QuotaTypeClosetShare, true + default: + return 0, false + } +} + +// todayEndUnix 返回当天 23:59:59 的秒级时间戳(广告激励当天有效)。 +func todayEndUnix() int64 { + now := time.Now() + return time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).Unix() +} diff --git a/internal/service/airecommend.go b/internal/service/airecommend.go new file mode 100644 index 0000000..8e2a068 --- /dev/null +++ b/internal/service/airecommend.go @@ -0,0 +1,256 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub" +) + +// AIRecommendService AI 穿搭推荐业务服务。 +type AIRecommendService struct { + outfitDao *mysqldao.OutfitDao + outfitItemDao *mysqldao.OutfitItemDao + clothesDao *mysqldao.ClothesDao + userDao *mysqldao.UserDao + horoscopeDao *mysqldao.HoroscopeDao + flowLogDao *mysqldao.AIRecommendFlowLogDao +} + +// NewAIRecommendService 创建 AI 推荐服务。 +func NewAIRecommendService() *AIRecommendService { + return &AIRecommendService{ + outfitDao: mysqldao.NewOutfitDao(), + outfitItemDao: mysqldao.NewOutfitItemDao(), + clothesDao: mysqldao.NewClothesDao(), + userDao: mysqldao.NewUserDao(), + horoscopeDao: mysqldao.NewHoroscopeDao(), + flowLogDao: mysqldao.NewAIRecommendFlowLogDao(), + } +} + +// Create 发起 AI 穿搭推荐:防并发、扣 AI 额度、基于衣橱与运势生成推荐,落库为待保存穿搭。 +func (s *AIRecommendService) Create(ctx context.Context, userID int64, request *dto.AIRecommendCreateRequest) (*dto.AIRecommendCreateResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.AIRecommendCreate") + defer span.End() + + // 1. 防并发锁。 + lockKey := fmt.Sprintf(redismodel.KeyLockAIRecommend, userID) + lockVal := genUUID() + ok, err := redisdao.TryLock(ctx, lockKey, lockVal, 180*time.Second) + if err != nil { + // Redis 异常:返回服务错误而非误报「请求过于频繁」。 + return nil, errcode.CacheError + } + if !ok { + return nil, errcode.TooManyRequests + } + defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }() + + // 2. 扣减 AI 每日额度(权威扣减)。 + if qerr := consumeDailyQuota(ctx, userID, QuotaTypeAI, 1, "AI 推荐消耗"); qerr != nil { + if qerr == errcode.QuotaInsufficient { + return nil, errcode.AIRecommendQuotaLimit + } + return nil, qerr + } + + // 失败时统一回退额度的兜底闭包。 + fail := func(bizErr *errcode.Error, reason string) *errcode.Error { + refundDailyQuota(ctx, userID, QuotaTypeAI, 1) + s.writeFlowLog(ctx, userID, "", AIFlowStatusFailed, reason) + return bizErr + } + + // 3. 加载用户可用衣物(已成功处理)。 + clothes, cerr := s.processedClothes(ctx, userID) + if cerr != nil { + return nil, fail(errcode.DatabaseError, "查询衣橱失败") + } + if len(clothes) == 0 { + return nil, fail(errcode.AIRecommendFailed.WithDetails("衣橱暂无可用衣物"), "衣橱为空") + } + + // 4. 计算星座与今日运势(best-effort)。 + zodiac, fortune := s.userFortune(ctx, userID) + + // 5. 调用大模型生成推荐。 + requestTime := nowUnix() + client := tokenhub.NewClient() + result, gerr := client.GenerateOutfitRecommendation(ctx, zodiac, fortune, clothes) + if gerr != nil { + return nil, fail(errcode.AIRecommendFailed, "大模型调用失败") + } + result.ValidateClothesIDs(clothes) + if len(result.Items) == 0 { + return nil, fail(errcode.AIRecommendFailed.WithDetails("未生成有效搭配"), "无有效单品") + } + + // 6. 落库为待用户保存的 AI 穿搭。 + scene := result.Scene + if scene == "" { + scene = request.Scene + } + outfit := &mysqlmodel.Outfit{ + OutfitUUID: genUUID(), + UserID: userID, + OutfitName: result.OutfitName, + OutfitDesc: result.Description, + Scene: scene, + Source: OutfitSourceAI, + IsPublic: OutfitPrivate, + AuditStatus: OutfitAuditPending, + Status: OutfitStatusUnsaved, + } + if err := s.outfitDao.OutfitCreate(ctx, outfit); err != nil { + return nil, fail(errcode.OutfitCreateFailed, "穿搭落库失败") + } + + clothesIDs := result.GetClothesIDs() + items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs)) + for _, cid := range clothesIDs { + items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid}) + } + if err := s.outfitItemDao.OutfitItemCreateBatch(ctx, items); err != nil { + // 明细落库失败:删除已建穿搭并回退额度,避免产生无明细的空推荐。 + _ = s.outfitDao.OutfitDeleteByUUID(ctx, outfit.OutfitUUID) + return nil, fail(errcode.OutfitCreateFailed, "穿搭明细落库失败") + } + + // 7. 写成功流水 + 当日计数。 + resultJSON, _ := json.Marshal(result) + if err := s.flowLogDao.AIRecommendFlowLogCreate(ctx, &mysqlmodel.AIRecommendFlowLog{ + RequestID: genUUID(), + UserID: userID, + ModelVersion: global.TokenhubSetting.Model, + ResultContent: string(resultJSON), + Status: AIFlowStatusSuccess, + RequestTime: requestTime, + ResponseTime: nowUnix(), + }); err != nil { + slog.WarnContext(ctx, "write ai recommend success flow log failed", "user_id", userID, "error", err) + } + recordDailyUsage(ctx, userID, QuotaTypeAI) + + // 8. 组装响应(衣物 UUID 来自校正后的 items)。 + outfitItems := make([]dto.OutfitItem, 0, len(result.Items)) + for _, it := range result.Items { + outfitItems = append(outfitItems, dto.OutfitItem{ClothesUUID: it.ClothesUUID}) + } + + return &dto.AIRecommendCreateResponse{ + OutfitUUID: outfit.OutfitUUID, + OutfitName: outfit.OutfitName, + OutfitDesc: outfit.OutfitDesc, + Scene: outfit.Scene, + Source: outfit.Source, + Status: outfit.Status, + OutfitItem: outfitItems, + }, nil +} + +// Save 将 AI 推荐生成的待保存穿搭保存到「我的搭配」(置为正常状态)。 +func (s *AIRecommendService) Save(ctx context.Context, userID int64, request *dto.AIRecommendSaveRequest) *errcode.Error { + ctx, span := global.Tracer.Start(ctx, "svc.AIRecommendSave") + defer span.End() + + outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.OutfitUUID) + if err != nil { + if isNotFound(err) { + return errcode.OutfitNotFound + } + return errcode.DatabaseError + } + if outfit.UserID != userID { + return errcode.OutfitNoPermission + } + // 仅待保存状态可保存;已正常则幂等返回。 + if outfit.Status == OutfitStatusNormal { + return nil + } + if outfit.Status != OutfitStatusUnsaved { + return errcode.OutfitUpdateFailed + } + + if uerr := s.outfitDao.OutfitUpdateByUUID(ctx, request.OutfitUUID, map[string]any{ + "status": OutfitStatusNormal, + }); uerr != nil { + return errcode.OutfitUpdateFailed + } + return nil +} + +// ---- 内部辅助 ---- + +// processedClothes 获取用户已成功处理、可用于推荐的衣物。 +func (s *AIRecommendService) processedClothes(ctx context.Context, userID int64) ([]mysqlmodel.Clothes, error) { + var result []mysqlmodel.Clothes + page, pageSize := 1, 200 + for { + list, total, err := s.clothesDao.ClothesListByUserID(ctx, userID, "", "", page, pageSize) + if err != nil { + return nil, err + } + for _, c := range list { + if c.ImageProcessStatus == ImageProcessSuccess { + result = append(result, c) + } + } + if len(list) == 0 || int64(page*pageSize) >= total { + break + } + page++ + } + return result, nil +} + +// userFortune 计算用户星座并读取今日运势(缺失时返回仅含星座的空运势)。 +func (s *AIRecommendService) userFortune(ctx context.Context, userID int64) (string, *tokenhub.FortuneItem) { + zodiac := "" + if user, err := s.userDao.UserGetByID(ctx, userID); err == nil && user.BirthDay != 0 { + zodiac = zodiacByUnix(user.BirthDay) + } + + fortune := &tokenhub.FortuneItem{Zodiac: zodiac} + if zodiac != "" { + if h, err := s.horoscopeDao.HoroscopeGetByZodiacAndDate(ctx, zodiac, todayStatDate()); err == nil && h != nil { + fortune = &tokenhub.FortuneItem{ + Zodiac: h.Zodiac, + Overall: h.OverallScore, + Love: h.LoveScore, + Work: h.CareerScore, + Money: h.WealthScore, + LuckyColor: h.LuckyColor, + LuckyNumber: h.LuckyNumber, + Description: h.DailyText, + } + } + } + return zodiac, fortune +} + +// writeFlowLog 写 AI 推荐流水(best-effort,失败仅告警不阻断主流程)。 +func (s *AIRecommendService) writeFlowLog(ctx context.Context, userID int64, resultContent string, status int8, failReason string) { + if err := s.flowLogDao.AIRecommendFlowLogCreate(ctx, &mysqlmodel.AIRecommendFlowLog{ + RequestID: genUUID(), + UserID: userID, + ModelVersion: global.TokenhubSetting.Model, + ResultContent: resultContent, + Status: status, + FailReason: failReason, + RequestTime: nowUnix(), + ResponseTime: nowUnix(), + }); err != nil { + slog.WarnContext(ctx, "write ai recommend flow log failed", "user_id", userID, "status", status, "error", err) + } +} diff --git a/internal/service/benefit_compensation.go b/internal/service/benefit_compensation.go new file mode 100644 index 0000000..cb1d9dd --- /dev/null +++ b/internal/service/benefit_compensation.go @@ -0,0 +1,112 @@ +package service + +import ( + "context" + "log/slog" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" +) + +// StartBenefitCompensationScheduler 启动权益发放补偿定时任务:周期性重试发放失败的权益。 +func StartBenefitCompensationScheduler() { + go func() { + slog.Info("benefit compensation scheduler started") + for { + func() { + defer func() { + if r := recover(); r != nil { + slog.Error("benefit compensation scheduler panic", "recover", r) + } + }() + runBenefitCompensation() + }() + time.Sleep(time.Minute) + } + }() +} + +// runBenefitCompensation 扫描待发放补偿任务并重试,单实例通过 Redis 分布式锁防重。 +func runBenefitCompensation() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + ctx, span := global.Tracer.Start(ctx, "scheduler.BenefitCompensation") + defer span.End() + + lockKey := "ztcloset:lock:benefit:compensation" + lockVal := genUUID() + ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, time.Minute) + if lerr != nil || !ok { + return + } + defer redisdao.Unlock(context.Background(), lockKey, lockVal) + + taskDao := mysqldao.NewBenefitGrantTaskDao() + + // 恢复超时的「发放中」任务(认领后崩溃的场景),使其回到待发放继续重试。 + _, _ = taskDao.BenefitGrantTaskRecoverStale(ctx, nowUnix()-int64((10*time.Minute).Seconds())) + + tasks, err := taskDao.BenefitGrantTaskListPending(ctx, 100) + if err != nil { + slog.ErrorContext(ctx, "list pending benefit tasks failed", "error", err) + return + } + if len(tasks) == 0 { + return + } + + s := NewOrderService() + for i := range tasks { + s.retryBenefitTask(ctx, &tasks[i]) + } +} + +// retryBenefitTask 重试单个补偿任务(幂等:先查发放流水避免重复发放)。 +func (s *OrderService) retryBenefitTask(ctx context.Context, task *mysqlmodel.BenefitGrantTask) { + taskDao := mysqldao.NewBenefitGrantTaskDao() + + // 重试次数超限,标记失败并告警,等待人工介入。 + if task.RetryCount >= task.MaxRetry { + _ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskFailed}) + slog.ErrorContext(ctx, "benefit grant task exhausted retries", + "order_no", task.OrderNo, "benefit_type", task.BenefitType, "retry_count", task.RetryCount) + return + } + + // 幂等判断:若发放流水已存在(上次发放成功但未置 success 的崩溃窗口),直接置成功。 + if granted, err := s.benefitAlreadyGranted(ctx, task); err == nil && granted { + _ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess}) + return + } else if err != nil { + slog.ErrorContext(ctx, "check benefit granted failed", + "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", err) + return + } + + // 执行发放(claim 防并发,内部处理 retry_count 原子自增与状态流转)。 + s.executeGrant(ctx, task) +} + +// benefitAlreadyGranted 判断该权益是否已发放(用于崩溃恢复幂等)。 +// VIP 查 VIP 流水;额度类查额度凭证(quota_grants)。 +func (s *OrderService) benefitAlreadyGranted(ctx context.Context, task *mysqlmodel.BenefitGrantTask) (bool, error) { + grantDao := mysqldao.NewQuotaGrantDao() + switch task.BenefitType { + case BenefitTypeVIP: + return s.vipDao.VIPFlowLogExistsByOrderNo(ctx, task.OrderNo) + case BenefitTypeCos: + return grantDao.QuotaGrantExistsByRefIDAndType(ctx, task.OrderNo, QuotaTypeCos) + case BenefitTypeAI: + return grantDao.QuotaGrantExistsByRefIDAndType(ctx, task.OrderNo, QuotaTypeAI) + case BenefitTypeClosetShare: + return grantDao.QuotaGrantExistsByRefIDAndType(ctx, task.OrderNo, QuotaTypeClosetShare) + case BenefitTypeOutfitShare: + return grantDao.QuotaGrantExistsByRefIDAndType(ctx, task.OrderNo, QuotaTypeOutfitShare) + default: + return false, nil + } +} diff --git a/internal/service/closet.go b/internal/service/closet.go new file mode 100644 index 0000000..1c80af1 --- /dev/null +++ b/internal/service/closet.go @@ -0,0 +1,366 @@ +package service + +import ( + "context" + "log/slog" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload" +) + +// ClosetShareService 衣柜分享业务服务。 +type ClosetShareService struct { + shareDao *mysqldao.ClosetShareDao + shareItemDao *mysqldao.ClosetShareItemDao + receiverDao *mysqldao.ClosetShareReceiverDao + clothesDao *mysqldao.ClothesDao + userDao *mysqldao.UserDao + auditLogDao *mysqldao.ClosetAuditLogDao +} + +// NewClosetShareService 创建衣柜分享服务。 +func NewClosetShareService() *ClosetShareService { + return &ClosetShareService{ + shareDao: mysqldao.NewClosetShareDao(), + shareItemDao: mysqldao.NewClosetShareItemDao(), + receiverDao: mysqldao.NewClosetShareReceiverDao(), + clothesDao: mysqldao.NewClothesDao(), + userDao: mysqldao.NewUserDao(), + auditLogDao: mysqldao.NewClosetAuditLogDao(), + } +} + +// Create 创建衣柜分享。部分分享需指定衣物;全量快照分享快照当前全部衣物。创建即自动审核通过。 +func (s *ClosetShareService) Create(ctx context.Context, userID int64, request *dto.ClosetShareCreateRequest) (*dto.ClosetShareCreateResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.ClosetShareCreate") + defer span.End() + + // 校验过期时间:必须晚于当前时间(0 表示不设置过期时间)。 + if request.ExpireTime > 0 && request.ExpireTime <= nowUnix() { + return nil, errcode.InvalidParams.WithDetails("过期时间必须晚于当前时间") + } + + // 1. 确定分享的衣物 ID 集合。 + var clothesIDs []int64 + switch request.ShareType { + case ShareTypePartial: + if len(request.ClothesUUIDs) == 0 { + return nil, errcode.InvalidParams.WithDetails("部分分享需选择衣物") + } + ids, cerr := s.resolveOwnedClothesIDs(ctx, userID, request.ClothesUUIDs) + if cerr != nil { + return nil, cerr + } + clothesIDs = ids + case ShareTypeSnapshot: + ids, err := s.allUserClothesIDs(ctx, userID) + if err != nil { + return nil, errcode.DatabaseError + } + clothesIDs = ids + default: + return nil, errcode.InvalidParams + } + + // 2. 消耗衣柜分享每日额度。 + if qerr := consumeDailyQuota(ctx, userID, QuotaTypeClosetShare, 1, "衣柜分享消耗"); qerr != nil { + return nil, qerr + } + + // 3. 创建分享。 + shareUUID := genUUID() + share := &mysqlmodel.ClosetShare{ + ShareUUID: shareUUID, + UserID: userID, + ShareType: request.ShareType, + ExpireTime: request.ExpireTime, + Status: ShareStatusNormal, + } + if err := s.shareDao.ClosetShareCreate(ctx, share); err != nil { + refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1) + return nil, errcode.DatabaseError + } + + // 4. 快照衣物明细(明细是分享核心数据,失败则回退额度并报错,避免产生空分享)。 + items := make([]mysqlmodel.ClosetShareItem, 0, len(clothesIDs)) + for _, cid := range clothesIDs { + items = append(items, mysqlmodel.ClosetShareItem{ShareUUID: shareUUID, ClothesID: cid}) + } + if err := s.shareItemDao.ClosetShareItemCreateBatch(ctx, items); err != nil { + refundDailyQuota(ctx, userID, QuotaTypeClosetShare, 1) + return nil, errcode.DatabaseError + } + + // 5. 自动审核通过,写审核日志(best-effort:失败仅告警不阻断创建)。 + if err := s.auditLogDao.ClosetAuditLogCreate(ctx, &mysqlmodel.ClosetAuditLog{ + TargetID: share.ID, + AuditResult: AuditResultPass, + AuditTime: nowUnix(), + }); err != nil { + slog.WarnContext(ctx, "write closet audit log failed", "share_id", share.ID, "error", err) + } + + recordDailyUsage(ctx, userID, QuotaTypeClosetShare) + + return &dto.ClosetShareCreateResponse{ShareUUID: shareUUID}, nil +} + +// Detail 查看分享详情(访客/属主视角)。 +func (s *ClosetShareService) Detail(ctx context.Context, userID int64, shareUUID string) (*dto.ClosetShareDetailResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.ClosetShareDetail") + defer span.End() + + share, err := s.shareDao.ClosetShareGetByUUID(ctx, shareUUID) + if err != nil { + if isNotFound(err) { + return nil, errcode.ShareNotFound + } + return nil, errcode.DatabaseError + } + + // 状态校验。 + switch share.Status { + case ShareStatusCancelled: + return nil, errcode.ShareCancelled + case ShareStatusBanned: + return nil, errcode.ShareNotFound + case ShareStatusExpired: + return nil, errcode.ShareExpired + } + // 时间过期校验。 + if share.ExpireTime > 0 && nowUnix() > share.ExpireTime { + _ = s.shareDao.ClosetShareUpdateStatus(ctx, shareUUID, ShareStatusExpired) + return nil, errcode.ShareExpired + } + + isOwner := share.UserID == userID + + // 访问计数(访客计入)。 + if !isOwner { + _, _ = redisdao.ClosetShareViewIncr(ctx, shareUUID) + } + + // 加载分享的衣物。 + shareItems, ierr := s.shareItemDao.ClosetShareItemListByShareUUID(ctx, shareUUID) + if ierr != nil { + return nil, errcode.DatabaseError + } + clothesIDs := make([]int64, 0, len(shareItems)) + for _, it := range shareItems { + clothesIDs = append(clothesIDs, it.ClothesID) + } + clothesList, cerr := s.clothesDao.ClothesGetByIDs(ctx, clothesIDs) + if cerr != nil { + return nil, errcode.DatabaseError + } + clothesResp := make([]dto.ClothesGetByUUIDResponse, 0, len(clothesList)) + for i := range clothesList { + clothesResp = append(clothesResp, buildClothesResponse(&clothesList[i])) + } + + // 记录接收关系:登录且非属主用户成功访问即视为收到,幂等,失败不阻断详情返回。 + if userID > 0 && !isOwner { + if rerr := s.receiverDao.ClosetShareReceiverUpsert(ctx, shareUUID, userID); rerr != nil { + slog.WarnContext(ctx, "record closet share receiver failed", "share_uuid", shareUUID, "user_id", userID, "error", rerr) + } + } + + // 分享者信息。 + ownerName, ownerAvatar := "", "" + if owner, oerr := s.userDao.UserGetByID(ctx, share.UserID); oerr == nil { + ownerName = owner.Username + ownerAvatar = upload.AvatarURL(owner.Avatar) + } + + return &dto.ClosetShareDetailResponse{ + ShareUUID: shareUUID, + OwnerName: ownerName, + OwnerAvatar: ownerAvatar, + Clothes: clothesResp, + ExpireTime: share.ExpireTime, + Status: share.Status, + IsOwner: isOwner, + }, nil +} + +// List 分页查询我的分享。 +func (s *ClosetShareService) List(ctx context.Context, userID int64, request *dto.ClosetShareListRequest) (*dto.ShareListResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.ClosetShareList") + defer span.End() + + page, pageSize := normalizePage(request.Page, request.PageSize) + + list, total, err := s.shareDao.ClosetShareListByUserID(ctx, userID, request.Status, page, pageSize) + if err != nil { + return nil, errcode.DatabaseError + } + + // 批量统计各分享的衣物数量。 + shareUUIDs := make([]string, 0, len(list)) + for _, sh := range list { + shareUUIDs = append(shareUUIDs, sh.ShareUUID) + } + countByShare := make(map[string]int, len(list)) + if allItems, ierr := s.shareItemDao.ClosetShareItemListByShareUUIDs(ctx, shareUUIDs); ierr == nil { + for _, it := range allItems { + countByShare[it.ShareUUID]++ + } + } + + items := make([]dto.ClosetShareItemResponse, 0, len(list)) + for _, sh := range list { + items = append(items, dto.ClosetShareItemResponse{ + ShareUUID: sh.ShareUUID, + ShareType: sh.ShareType, + ClothesCount: countByShare[sh.ShareUUID], + ExpireTime: sh.ExpireTime, + Status: sh.Status, + }) + } + + return &dto.ShareListResponse{ + PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, + List: items, + }, nil +} + +// ListReceived 分页查询"分享给我的"分享。 +func (s *ClosetShareService) ListReceived(ctx context.Context, userID int64, request *dto.ClosetShareListRequest) (*dto.ShareListResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.ClosetShareReceivedList") + defer span.End() + + page, pageSize := normalizePage(request.Page, request.PageSize) + + list, total, err := s.receiverDao.ClosetShareReceivedList(ctx, userID, request.Status, page, pageSize) + if err != nil { + return nil, errcode.DatabaseError + } + + // 批量统计各分享的衣物数量。 + shareUUIDs := make([]string, 0, len(list)) + ownerIDs := make([]int64, 0, len(list)) + for _, sh := range list { + shareUUIDs = append(shareUUIDs, sh.ShareUUID) + ownerIDs = append(ownerIDs, sh.UserID) + } + countByShare := make(map[string]int, len(list)) + if allItems, ierr := s.shareItemDao.ClosetShareItemListByShareUUIDs(ctx, shareUUIDs); ierr == nil { + for _, it := range allItems { + countByShare[it.ShareUUID]++ + } + } + + // 批量查询分享者信息。 + ownerByID := make(map[int64]mysqlmodel.User, len(ownerIDs)) + if owners, oerr := s.userDao.UserGetByIDs(ctx, ownerIDs); oerr == nil { + for _, u := range owners { + ownerByID[u.ID] = u + } + } + + items := make([]dto.ClosetShareItemResponse, 0, len(list)) + for _, sh := range list { + ownerName, ownerAvatar := "", "" + if owner, ok := ownerByID[sh.UserID]; ok { + ownerName = owner.Username + ownerAvatar = upload.AvatarURL(owner.Avatar) + } + items = append(items, dto.ClosetShareItemResponse{ + ShareUUID: sh.ShareUUID, + ShareType: sh.ShareType, + ClothesCount: countByShare[sh.ShareUUID], + ExpireTime: sh.ExpireTime, + Status: sh.Status, + OwnerName: ownerName, + OwnerAvatar: ownerAvatar, + }) + } + + return &dto.ShareListResponse{ + PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, + List: items, + }, nil +} + +// Cancel 取消分享(校验归属)。 +func (s *ClosetShareService) Cancel(ctx context.Context, userID int64, request *dto.ClosetShareCancelRequest) (*dto.CollectResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.ClosetShareCancel") + defer span.End() + + share, err := s.shareDao.ClosetShareGetByUUID(ctx, request.ShareUUID) + if err != nil { + if isNotFound(err) { + return nil, errcode.ShareNotFound + } + return nil, errcode.DatabaseError + } + if share.UserID != userID { + return nil, errcode.Forbidden + } + if err := s.shareDao.ClosetShareUpdateStatus(ctx, request.ShareUUID, ShareStatusCancelled); err != nil { + return nil, errcode.DatabaseError + } + return &dto.CollectResponse{}, nil +} + +// ---- 内部辅助 ---- + +// resolveOwnedClothesIDs 校验衣物均属于用户,返回有序去重的 clothesID 列表。 +func (s *ClosetShareService) resolveOwnedClothesIDs(ctx context.Context, userID int64, uuids []string) ([]int64, *errcode.Error) { + dedup := make([]string, 0, len(uuids)) + seen := make(map[string]struct{}, len(uuids)) + for _, u := range uuids { + if _, ok := seen[u]; ok { + continue + } + seen[u] = struct{}{} + dedup = append(dedup, u) + } + + clothesList, err := s.clothesDao.ClothesGetByUUIDs(ctx, dedup) + if err != nil { + return nil, errcode.DatabaseError + } + byUUID := make(map[string]mysqlmodel.Clothes, len(clothesList)) + for _, c := range clothesList { + byUUID[c.ClothesUUID] = c + } + + ids := make([]int64, 0, len(dedup)) + for _, u := range dedup { + c, ok := byUUID[u] + if !ok { + return nil, errcode.ClothesNotFound + } + if c.UserID != userID { + return nil, errcode.ClothesNoPermission + } + ids = append(ids, c.ID) + } + return ids, nil +} + +// allUserClothesIDs 获取用户当前全部衣物 ID(分页遍历)。 +func (s *ClosetShareService) allUserClothesIDs(ctx context.Context, userID int64) ([]int64, error) { + var ids []int64 + page, pageSize := 1, 200 + for { + list, total, err := s.clothesDao.ClothesListByUserID(ctx, userID, "", "", page, pageSize) + if err != nil { + return nil, err + } + for _, c := range list { + ids = append(ids, c.ID) + } + if len(list) == 0 || int64(page*pageSize) >= total { + break + } + page++ + } + return ids, nil +} diff --git a/internal/service/clothes.go b/internal/service/clothes.go new file mode 100644 index 0000000..3152944 --- /dev/null +++ b/internal/service/clothes.go @@ -0,0 +1,472 @@ +package service + +import ( + "context" + "fmt" + "io" + "log/slog" + "mime/multipart" + "time" + + "go.opentelemetry.io/otel/trace" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/classification" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/matting" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// imageProcessSem 限制异步抠图处理的并发数,避免瞬时大量上传打满下游抠图/分类服务。 +var imageProcessSem = make(chan struct{}, 10) + +// ClothesService 衣物业务服务。 +type ClothesService struct { + clothesDao *mysqldao.ClothesDao + userDao *mysqldao.UserDao +} + +// NewClothesService 创建衣物服务。 +func NewClothesService() *ClothesService { + return &ClothesService{ + clothesDao: mysqldao.NewClothesDao(), + userDao: mysqldao.NewUserDao(), + } +} + +// Upload 上传衣物图片:同步完成校验、扣额度、建记录后立即返回, +// 抠图与分类在后台 goroutine 异步执行并回填状态。 +func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *multipart.FileHeader) (*dto.ClothesUploadResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.ClothesUpload") + defer span.End() + + if fileHeader == nil || fileHeader.Size == 0 { + return nil, errcode.UploadFileEmpty + } + + // 1. 校验扩展名与大小。 + ext := upload.FileGetExt(fileHeader.Filename) + if !upload.FileCheckAllowExt(ext, global.UploadSetting.AllowedExtensions) { + return nil, errcode.UploadFileExtNotAllow + } + if !upload.FileCheckMaxSize(fileHeader.Size, int64(global.UploadSetting.MaxSizeMB)) { + return nil, errcode.UploadFileTooLarge + } + + // 2. 读取原图字节(必须同步读取,请求返回后 multipart 临时文件会被清理)。 + src, oerr := fileHeader.Open() + if oerr != nil { + return nil, errcode.UploadFileSaveFailed + } + imageData, rerr := io.ReadAll(src) + _ = src.Close() + if rerr != nil { + return nil, errcode.UploadFileSaveFailed + } + + // 3. 校验文件内容为真实图片(magic bytes),防止伪造扩展名上传恶意文件。 + if !upload.FileCheckImageMagic(imageData) { + return nil, errcode.UploadFileExtNotAllow + } + + // 4. 扣减 COS 上传额度(权威扣减,先到期凭证先用,再扣免费额度)。 + if qerr := consumeCosQuota(ctx, userID, 1, 0, "上传衣物消耗"); qerr != nil { + return nil, qerr + } + + // 5. 创建衣物记录,初始状态为排队中。 + clothesUUID := genUUID() + clothes := &mysqlmodel.Clothes{ + ClothesUUID: clothesUUID, + UserID: userID, + ImageSize: fileHeader.Size / 1024, + ImageProcessStatus: ImageProcessQueuing, + } + if err := s.clothesDao.ClothesCreate(ctx, clothes); err != nil { + // 建记录失败,回退已扣额度。 + refundCosQuota(ctx, userID, 1, 0, "上传失败回退") + return nil, errcode.ClothesCreateFailed + } + + // 6. 异步抠图 + 分类。 + go s.processImageAsync(ctx, userID, clothesUUID, imageData) + + return &dto.ClothesUploadResponse{ + ClothesUUID: clothesUUID, + ImageProcessStatus: clothes.ImageProcessStatus, + }, nil +} + +// processImageAsync 后台执行抠图、上传 COS、分类,并回填衣物记录。 +func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int64, clothesUUID string, imageData []byte) { + // 限流:获取信号量,超出并发上限时阻塞等待。 + imageProcessSem <- struct{}{} + defer func() { <-imageProcessSem }() + + // 派生独立 ctx:继承父 trace 信息,但切断取消信号,避免复用已结束的请求 ctx。 + ctx, cancel := context.WithTimeout(trace.ContextWithSpanContext(context.Background(), trace.SpanContextFromContext(parentCtx)), 3*time.Minute) + defer cancel() + + ctx, span := global.Tracer.Start(ctx, "svc.ClothesProcessImageAsync") + defer span.End() + + defer func() { + if r := recover(); r != nil { + slog.ErrorContext(ctx, "clothes image process panic", "clothes_uuid", clothesUUID, "recover", r) + s.markProcessFailed(ctx, userID, clothesUUID, "内部错误") + } + }() + + dao := s.clothesDao + + // 1. 抠图。 + _ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessMatting}) + mattingClient := matting.NewClient() + processedData, err := retryCall(ctx, global.MattingModelSetting.MaxRetry, "matting", func() ([]byte, error) { + return mattingClient.Matting(ctx, imageData) + }) + if err != nil { + slog.ErrorContext(ctx, "matting failed", "clothes_uuid", clothesUUID, "error", err) + s.markProcessFailed(ctx, userID, clothesUUID, "图片抠图失败") + return + } + + // 2. 处理结果落临时目录并上传 COS。对象键结构:用户UUID/用户ID/图片文件名。 + processedName := genUUID() + ".png" + // 获取用户 UUID,作为 COS 对象键第一层(uuid/userid/图片)。 + user, gerr := s.userDao.UserGetByID(ctx, userID) + if gerr != nil { + slog.ErrorContext(ctx, "get user for cos key failed", "clothes_uuid", clothesUUID, "error", gerr) + s.markProcessFailed(ctx, userID, clothesUUID, "获取用户信息失败") + return + } + subDir := fmt.Sprintf("%s/%d", user.UserUUID, userID) + if _, serr := upload.FileSaveTmpBytes(ctx, processedData, processedName, subDir); serr != nil { + slog.ErrorContext(ctx, "save processed image failed", "clothes_uuid", clothesUUID, "error", serr) + s.markProcessFailed(ctx, userID, clothesUUID, "图片保存失败") + return + } + cosKey := fmt.Sprintf("%s/%s", subDir, processedName) + if uerr := upload.FileUploadCos(ctx, cosKey); uerr != nil { + slog.ErrorContext(ctx, "upload processed image to cos failed", "clothes_uuid", clothesUUID, "error", uerr) + s.markProcessFailed(ctx, userID, clothesUUID, "图片上传失败") + return + } + + // 上传成功后回填前检查衣物是否仍存在:用户可能在处理期间删除衣物, + // 若已删除则清理刚上传的对象,避免 COS 残留(额度已由删除事务回退,无需再处理)。 + if _, gerr := dao.ClothesGetByUUID(ctx, clothesUUID); isNotFound(gerr) { + if derr := upload.FileDeleteCos(ctx, cosKey); derr != nil { + slog.ErrorContext(ctx, "delete orphan cos image failed", "clothes_uuid", clothesUUID, "cos_key", cosKey, "error", derr) + } + slog.InfoContext(ctx, "clothes deleted during processing, cleaned uploaded image", "clothes_uuid", clothesUUID) + return + } + + // 3. 分类。 + _ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessClassifying}) + classifyClient := classification.NewClient() + result, cerr := retryCall(ctx, global.ClassificationModelSetting.MaxRetry, "classify", func() (*classification.ClassifyResult, error) { + return classifyClient.Classify(ctx, processedData) + }) + if cerr != nil { + slog.ErrorContext(ctx, "classify failed", "clothes_uuid", clothesUUID, "error", cerr) + // 分类失败仍保留已抠图结果,但条件标记失败并回退以便用户手动补全。 + _ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"processed_image": cosKey}) + s.markProcessFailed(ctx, userID, clothesUUID, "图片分类失败") + return + } + + // 4. 回填分类结果与处理后图片,标记成功。注意主色列名为 color。 + updates := map[string]any{ + "category_main": result.CategoryMain, + "category_sub": result.CategorySub, + "style": result.Style, + "name": buildDefaultClothesName(result), + "color": result.ColorMain, + "color_sub": result.ColorSub, + "thickness": result.Thickness, + "version": result.Version, + "material": result.Material, + "pattern": result.Pattern, + "sleeve_type": result.SleeveType, + "clothes_type": result.ClothesType, + "neckline_type": result.NecklineType, + "formality": result.Formality, + "occasions": result.Occasions, + "visual_impact": result.VisualImpact, + "processed_image": cosKey, + "image_process_status": ImageProcessSuccess, + "fail_reason": "", + } + if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, updates); uerr != nil { + slog.ErrorContext(ctx, "update clothes after classify failed", "clothes_uuid", clothesUUID, "error", uerr) + } +} + +// buildDefaultClothesName 根据分类结果生成衣服默认名称(风格+小类+大类,空字段跳过)。 +func buildDefaultClothesName(result *classification.ClassifyResult) string { + name := "" + if result.Style != "" { + name += result.Style + } + if result.CategorySub != "" { + name += result.CategorySub + } + if result.CategoryMain != "" { + name += result.CategoryMain + } + return name +} + +// retryCall 对 fn 进行最多 maxRetry 次额外重试(总尝试 maxRetry+1 次)。 +// 重试间隔 1s;ctx 到期则提前终止并返回 ctx.Err()。 +func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func() (T, error)) (T, error) { + var zero T + var err error + for attempt := 0; attempt <= maxRetry; attempt++ { + result, e := fn() + if e == nil { + return result, nil + } + err = e + if attempt >= maxRetry { + break + } + slog.WarnContext(ctx, "model call failed, retrying", "name", name, "attempt", attempt+1, "error", e) + select { + case <-ctx.Done(): + return zero, ctx.Err() + case <-time.After(time.Second): + } + } + return zero, err +} + +// markProcessFailed 标记处理失败并回退上传额度。 +// 单事务(行锁 -> 置失败 -> 回退额度 + 写流水),与删除事务(FOR UPDATE)通过行锁互斥: +// 两路径串行化保证「处理失败回退」与「删除回退」恰好执行一次,消除漏退/双退窗口。 +func (s *ClothesService) markProcessFailed(ctx context.Context, userID int64, clothesUUID, reason string) { + vipExpire := getUserVIPExpire(ctx, userID) + err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var c mysqlmodel.Clothes + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("clothes_uuid = ?", clothesUUID). + First(&c).Error; err != nil { + if isNotFound(err) { + // 衣物已删除:删除事务已回退额度,跳过。 + return nil + } + return err + } + // 已是失败态:说明失败回退已处理过,跳过。 + if c.ImageProcessStatus == ImageProcessFailed { + return nil + } + + // 置失败。 + if err := tx.Model(&mysqlmodel.Clothes{}). + Where("clothes_uuid = ?", clothesUUID). + Updates(map[string]any{"image_process_status": ImageProcessFailed, "fail_reason": reason}).Error; err != nil { + return err + } + + // 同事务回退额度(优先免费额度,不足回退凭证)。 + return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "衣物处理失败回退") + }) + if err != nil { + slog.ErrorContext(ctx, "mark process failed transaction error", "clothes_uuid", clothesUUID, "error", err) + } +} + +// ProcessStatusGetByUUID 查询衣物图片处理状态。 +func (s *ClothesService) ProcessStatusGetByUUID(ctx context.Context, userID int64, request *dto.ClothesProcessStatusGetByUUIDRequest) (*dto.ClothesProcessStatusGetByUUIDResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.ClothesProcessStatusGetByUUID") + defer span.End() + + clothes, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID) + if cerr != nil { + return nil, cerr + } + return &dto.ClothesProcessStatusGetByUUIDResponse{ + ImageProcessStatus: clothes.ImageProcessStatus, + FailReason: clothes.FailReason, + }, nil +} + +// GetByUUID 获取衣物详情。 +func (s *ClothesService) GetByUUID(ctx context.Context, userID int64, request *dto.ClothesGetByUUIDRequest) (*dto.ClothesGetByUUIDResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.ClothesGetByUUID") + defer span.End() + + clothes, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID) + if cerr != nil { + return nil, cerr + } + response := buildClothesResponse(clothes) + return &response, nil +} + +// UpdateByUUID 修改衣物属性(仅更新非空字段)。 +func (s *ClothesService) UpdateByUUID(ctx context.Context, userID int64, request *dto.ClothesUpdateByUUIDRequest) (*dto.ClothesUpdateByUUIDResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.ClothesUpdateByUUID") + defer span.End() + + if _, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID); cerr != nil { + return nil, cerr + } + + updates := map[string]any{} + setIfNotEmpty(updates, "category_main", request.CategoryMain) + setIfNotEmpty(updates, "category_sub", request.CategorySub) + setIfNotEmpty(updates, "style", request.Style) + setIfNotEmpty(updates, "color", request.ColorMain) // 主色列名为 color + setIfNotEmpty(updates, "color_sub", request.ColorSub) + setIfNotEmpty(updates, "thickness", request.Thickness) + setIfNotEmpty(updates, "version", request.Version) + setIfNotEmpty(updates, "material", request.Material) + setIfNotEmpty(updates, "pattern", request.Pattern) + setIfNotEmpty(updates, "sleeve_type", request.SleeveType) + setIfNotEmpty(updates, "clothes_type", request.ClothesType) + setIfNotEmpty(updates, "neckline_type", request.NecklineType) + setIfNotEmpty(updates, "formality", request.Formality) + setIfNotEmpty(updates, "occasions", request.Occasions) + setIfNotEmpty(updates, "visual_impact", request.VisualImpact) + setIfNotEmpty(updates, "name", request.Name) + setIfNotEmpty(updates, "remark", request.Remark) + + if len(updates) == 0 { + return &dto.ClothesUpdateByUUIDResponse{}, nil + } + if err := s.clothesDao.ClothesUpdateByUUID(ctx, request.ClothesUUID, updates); err != nil { + return nil, errcode.ClothesUpdateFailed + } + return &dto.ClothesUpdateByUUIDResponse{}, nil +} + +// DeleteByUUID 删除衣物(软删除)。 +func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request *dto.ClothesDeleteByUUIDRequest) (*dto.ClothesDeleteByUUIDResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.ClothesDeleteByUUID") + defer span.End() + + if _, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID); cerr != nil { + return nil, cerr + } + + // 事务内锁定衣物行 -> 读最新状态 -> 软删除 -> 条件回退额度 -> 写回退流水, + // 数值变更与流水同事务强一致,并避免与异步处理失败回退并发导致漏退/双退。 + vipExpire := getUserVIPExpire(ctx, userID) + var processedImage string + err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var c mysqlmodel.Clothes + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("clothes_uuid = ?", request.ClothesUUID). + First(&c).Error; err != nil { + return err + } + processedImage = c.ProcessedImage + if err := tx.Where("clothes_uuid = ?", request.ClothesUUID).Delete(&mysqlmodel.Clothes{}).Error; err != nil { + return err + } + // 失败状态已在处理失败路径回退,不再回退,避免双重回退。 + if c.ImageProcessStatus == ImageProcessFailed { + return nil + } + // 同事务回退额度(优先免费额度,不足回退凭证)。 + return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "删除衣物回退") + }) + if err != nil { + if isNotFound(err) { + return nil, errcode.ClothesNotFound + } + return nil, errcode.ClothesDeleteFailed + } + + // 事务提交成功后清理 COS 图片(尽力而为,失败仅记录日志,不影响删除结果)。 + if processedImage != "" { + if derr := upload.FileDeleteCos(ctx, processedImage); derr != nil { + slog.WarnContext(ctx, "delete clothes cos image failed", "clothes_uuid", request.ClothesUUID, "cos_key", processedImage, "error", derr) + } + } + + return &dto.ClothesDeleteByUUIDResponse{}, nil +} + +// List 分页查询用户衣物。 +func (s *ClothesService) List(ctx context.Context, userID int64, request *dto.ClothesListRequest) (*dto.ClothesListResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.ClothesList") + defer span.End() + + page, pageSize := normalizePage(request.Page, request.PageSize) + + list, total, err := s.clothesDao.ClothesListByUserID(ctx, userID, request.CategoryMain, request.CategorySub, page, pageSize) + if err != nil { + return nil, errcode.DatabaseError + } + + items := make([]dto.ClothesGetByUUIDResponse, 0, len(list)) + for i := range list { + items = append(items, buildClothesResponse(&list[i])) + } + + return &dto.ClothesListResponse{ + PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, + List: items, + }, nil +} + +// getOwnedClothes 查询衣物并校验归属。 +func (s *ClothesService) getOwnedClothes(ctx context.Context, clothesUUID string, userID int64) (*mysqlmodel.Clothes, *errcode.Error) { + clothes, err := s.clothesDao.ClothesGetByUUID(ctx, clothesUUID) + if err != nil { + if isNotFound(err) { + return nil, errcode.ClothesNotFound + } + return nil, errcode.DatabaseError + } + if clothes.UserID != userID { + return nil, errcode.ClothesNoPermission + } + return clothes, nil +} + +// buildClothesResponse 将衣物模型映射为响应,processed_image 转为可访问 URL。 +func buildClothesResponse(c *mysqlmodel.Clothes) dto.ClothesGetByUUIDResponse { + processed := c.ProcessedImage + if processed != "" { + processed = upload.GetCosUrl(processed) + } + return dto.ClothesGetByUUIDResponse{ + ClothesUUID: c.ClothesUUID, + CategoryMain: c.CategoryMain, + CategorySub: c.CategorySub, + Style: c.Style, + ColorMain: c.ColorMain, + ColorSub: c.ColorSub, + Thickness: c.Thickness, + Version: c.Version, + Material: c.Material, + Pattern: c.Pattern, + SleeveType: c.SleeveType, + ClothesType: c.ClothesType, + NecklineType: c.NecklineType, + Formality: c.Formality, + Occasions: c.Occasions, + VisualImpact: c.VisualImpact, + Name: c.Name, + Remark: c.Remark, + ProcessedImage: processed, + } +} + +// setIfNotEmpty 当值非空时写入 updates map。 +func setIfNotEmpty(m map[string]any, key, val string) { + if val != "" { + m[key] = val + } +} diff --git a/internal/service/collect.go b/internal/service/collect.go new file mode 100644 index 0000000..04d9442 --- /dev/null +++ b/internal/service/collect.go @@ -0,0 +1,183 @@ +package service + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" +) + +// CollectService 收藏业务服务。 +type CollectService struct { + collectDao *mysqldao.CollectDao + outfitDao *mysqldao.OutfitDao + outfitItemDao *mysqldao.OutfitItemDao + clothesDao *mysqldao.ClothesDao +} + +// NewCollectService 创建收藏服务。 +func NewCollectService() *CollectService { + return &CollectService{ + collectDao: mysqldao.NewCollectDao(), + outfitDao: mysqldao.NewOutfitDao(), + outfitItemDao: mysqldao.NewOutfitItemDao(), + clothesDao: mysqldao.NewClothesDao(), + } +} + +// Collect 收藏或取消收藏社区穿搭。 +func (s *CollectService) Collect(ctx context.Context, userID int64, request *dto.CollectRequest) (*dto.CollectResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.CollectCollect") + defer span.End() + + outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.TargetUUID) + if err != nil { + if isNotFound(err) { + return nil, errcode.TargetNotFound + } + return nil, errcode.DatabaseError + } + // 非本人仅可收藏公开可见的穿搭。 + if outfit.UserID != userID && !isOutfitPublicVisible(outfit) { + return nil, errcode.OutfitNoPermission + } + targetID := outfit.ID + + existing, err := s.collectDao.CollectGetByTargetAndUser(ctx, CollectTypeOutfit, targetID, userID) + notExist := false + if err != nil { + if isNotFound(err) { + notExist = true + } else { + return nil, errcode.DatabaseError + } + } + + switch request.Action { + case ActionDo: // 收藏 + if cerr := s.collectDao.CollectUpsertOn(ctx, &mysqlmodel.Collect{ + CollectType: CollectTypeOutfit, + TargetID: targetID, + UserID: userID, + Status: InteractStatusOn, + }); cerr != nil { + return nil, errcode.CollectFailed + } + _ = redisdao.CollectAdd(ctx, CollectTypeOutfit, targetID, userID) + + case ActionCancel: // 取消收藏 + if !notExist && existing.Status == InteractStatusOn { + if uerr := s.collectDao.CollectUpdateStatus(ctx, existing.ID, InteractStatusOff); uerr != nil { + return nil, errcode.CollectFailed + } + _ = redisdao.CollectRemove(ctx, CollectTypeOutfit, targetID, userID) + } + } + + return &dto.CollectResponse{}, nil +} + +// List 分页查询我收藏的穿搭。 +func (s *CollectService) List(ctx context.Context, userID int64, request *dto.CollectListRequest) (*dto.CollectListResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.CollectList") + defer span.End() + + page, pageSize := normalizePage(request.Page, request.PageSize) + + pageIDs, total, err := s.collectDao.CollectListTargetIDsByUserPage(ctx, CollectTypeOutfit, userID, page, pageSize) + if err != nil { + return nil, errcode.DatabaseError + } + + items := make([]dto.OutfitGetByUUIDResponse, 0, len(pageIDs)) + for _, oid := range pageIDs { + outfit, gerr := s.outfitDao.OutfitGetByID(ctx, oid) + if gerr != nil { + // 穿搭可能已删除,跳过。 + continue + } + outfitItems, uerr := s.itemResponses(ctx, outfit.ID) + if uerr != nil { + continue + } + items = append(items, dto.OutfitGetByUUIDResponse{ + OutfitCreateResponse: buildOutfitCreateResponse(outfit, outfitItems), + }) + } + + return &dto.CollectListResponse{ + PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, + List: items, + }, nil +} + +// GetByTarget 查询穿搭收藏状态与收藏数(Redis 优先,回落 MySQL)。 +func (s *CollectService) GetByTarget(ctx context.Context, userID int64, request *dto.CollectGetByTargetRequest) (*dto.CollectGetByTargetResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.CollectGetByTarget") + defer span.End() + + outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.TargetUUID) + if err != nil { + if isNotFound(err) { + return nil, errcode.TargetNotFound + } + return nil, errcode.DatabaseError + } + // 非本人仅可查询公开可见穿搭的收藏状态。 + if outfit.UserID != userID && !isOutfitPublicVisible(outfit) { + return nil, errcode.OutfitNoPermission + } + targetID := outfit.ID + + // 是否已收藏:Redis 优先,回落 MySQL。 + isCollected, err := redisdao.CollectIsMember(ctx, CollectTypeOutfit, targetID, userID) + if err != nil { + if rec, derr := s.collectDao.CollectGetByTargetAndUser(ctx, CollectTypeOutfit, targetID, userID); derr == nil { + isCollected = rec.Status == InteractStatusOn + } + } + + // 收藏总数:Redis 优先,回落 MySQL。 + count, err := redisdao.CollectCount(ctx, CollectTypeOutfit, targetID) + if err != nil || count == 0 { + if dbCnt, derr := s.collectDao.CollectCountByTarget(ctx, CollectTypeOutfit, targetID); derr == nil { + count = dbCnt + } + } + + return &dto.CollectGetByTargetResponse{ + UserIsCollected: isCollected, + CollectCount: count, + }, nil +} + +// itemResponses 获取某穿搭的衣物明细(含图片 URL)。 +func (s *CollectService) itemResponses(ctx context.Context, outfitID int64) ([]dto.OutfitItemResponse, error) { + its, err := s.outfitItemDao.OutfitItemListByOutfitID(ctx, outfitID) + if err != nil { + return nil, err + } + ids := make([]int64, 0, len(its)) + for _, it := range its { + ids = append(ids, it.ClothesID) + } + clothesList, err := s.clothesDao.ClothesGetByIDs(ctx, ids) + if err != nil { + return nil, err + } + clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesList)) + for _, c := range clothesList { + clothesByID[c.ID] = c + } + resps := make([]dto.OutfitItemResponse, 0, len(its)) + for _, it := range its { + if c, ok := clothesByID[it.ClothesID]; ok { + resps = append(resps, buildOutfitItemResponse(&c)) + } + } + return resps, nil +} diff --git a/internal/service/constants.go b/internal/service/constants.go new file mode 100644 index 0000000..4bc9f87 --- /dev/null +++ b/internal/service/constants.go @@ -0,0 +1,213 @@ +package service + +// 本文件集中定义各业务域的状态/类型枚举常量,消除魔法数字。 +// 取值与 internal/mysqlmodel 各表字段注释保持一致。 + +// ===== 用户 User ===== +const ( + UserStatusNormal int8 = 1 // 正常 + UserStatusTempBan int8 = 2 // 临时禁用 + UserStatusPermBan int8 = 3 // 永久禁用 + UserStatusDeactivated int8 = 4 // 已注销 + + SexMale int8 = 1 // 男 + SexFemale int8 = 2 // 女 + + VIPTypeNone int8 = 1 // 非会员 + VIPTypeMonthly int8 = 2 // 月度会员 + VIPTypeYearly int8 = 3 // 年度会员 + VIPTypeTrial int8 = 4 // 体验会员(新用户注册赠送,天数由 UserDefaultQuota.GiftVIPDay 配置) +) + +// ===== 配额 Quota ===== +const ( + QuotaTypeCos int8 = 1 // COS 上传 + QuotaTypeAI int8 = 2 // AI 推荐 + QuotaTypeOutfitShare int8 = 3 // 社区分享 + QuotaTypeClosetShare int8 = 4 // 衣柜分享 + + // 额度凭证来源(quota_grants.source) + QuotaGrantSourceMember int8 = 1 // 会员包(跟随 VIP 有效期) + QuotaGrantSourceAddon int8 = 2 // 加量包(独立有效期) + QuotaGrantSourceAdReward int8 = 3 // 广告激励(当天有效) + + // ChangeTarget 调整额度类型 + QuotaChangeTargetAdjust int8 = 1 // 配额调整 + QuotaChangeTargetUse int8 = 2 // 使用 + + // ChangeType 变更类型 + QuotaChangeTypePurchase int8 = 1 // 购买获赠 + QuotaChangeTypeActivity int8 = 2 // 活动赠送 + QuotaChangeTypeConsume int8 = 3 // 上传消耗 + QuotaChangeTypeRefund int8 = 4 // 删除回退 + QuotaChangeTypeManual int8 = 5 // 人工调整 + QuotaChangeTypeRefundRevoke int8 = 6 // 退款回收 +) + +// 新用户默认免费额度(内置兜底值)。 +// 优先使用 global.UserDefaultQuotaSetting 的配置值(配置为 0 时生效,表示不赠送该类免费额度); +// 仅在该项未配置(指针为 nil)或配置为负数时回落这些常量。 +const ( + DefaultQuotaCos int64 = 20 // 免费上传额度 + DefaultQuotaAI int64 = 3 // 免费 AI 推荐额度 + DefaultQuotaOutfitShare int64 = 10 // 免费社区分享额度 + DefaultQuotaClosetShare int64 = 5 // 免费衣柜分享额度 +) + +// 新用户注册赠送体验会员的内置兜底天数。 +// 优先使用 UserDefaultQuota.GiftVIPDay 的配置值(配置为 0 时生效,表示不赠送); +// 仅在该项未配置(指针为 nil)或配置为负数时回落此常量。 +const DefaultVipDays int64 = 3 + +// ===== 衣物 Clothes 图片处理状态 ===== +const ( + ImageProcessMatting int8 = 1 // 抠图中 + ImageProcessClassifying int8 = 2 // 分类中 + ImageProcessQueuing int8 = 3 // 排队中 + ImageProcessSuccess int8 = 4 // 成功 + ImageProcessFailed int8 = 5 // 失败 +) + +// ===== 穿搭 Outfit ===== +const ( + OutfitSourceUser int8 = 1 // 用户创建 + OutfitSourceAI int8 = 2 // AI 推荐 + + OutfitPrivate int8 = 1 // 不公开 + OutfitPublic int8 = 2 // 公开 + + OutfitAuditPending int8 = 1 // 待审核 + OutfitAuditPass int8 = 2 // 审核通过 + OutfitAuditReject int8 = 3 // 审核不通过 + + OutfitStatusNormal int8 = 1 // 正常 + OutfitStatusBanned int8 = 2 // 封禁 + OutfitStatusUnsaved int8 = 3 // 待用户保存(AI 推荐生成后未保存) +) + +// ===== 衣柜分享 ClosetShare ===== +const ( + ShareTypePartial int8 = 1 // 部分衣物分享 + ShareTypeSnapshot int8 = 2 // 全量快照分享 + + ShareStatusNormal int8 = 1 // 正常 + ShareStatusCancelled int8 = 2 // 用户取消 + ShareStatusExpired int8 = 3 // 已过期 + ShareStatusBanned int8 = 4 // 管理员封禁 +) + +// ===== 审核日志 AuditLog ===== +const ( + AuditResultPass int8 = 1 // 通过 + AuditResultReject int8 = 2 // 驳回 +) + +// ===== 收藏 / 点赞 ===== +const ( + CollectTypeOutfit int8 = 1 // 社区穿搭 + LikeTypeOutfit int8 = 1 // 社区穿搭 + + InteractStatusOn int8 = 1 // 已收藏 / 已点赞 + InteractStatusOff int8 = 0 // 已取消 + + ActionDo int8 = 1 // 收藏 / 点赞 + ActionCancel int8 = 2 // 取消 +) + +// ===== 通知 Notification ===== +const ( + NotificationTypeLike int8 = 1 // 点赞通知 + NotificationTypeOrder int8 = 2 // 订单通知 + NotificationTypeSystem int8 = 3 // 系统通知 + + NotificationUnread int8 = 1 // 未读 + NotificationRead int8 = 2 // 已读 +) + +// ===== 商品 Goods ===== +const ( + GoodsTypeMember int8 = 1 // 会员包 + GoodsTypeUpload int8 = 2 // 上传包 + GoodsTypeAIRecommend int8 = 3 // AI 推荐包 + GoodsTypeClosetShare int8 = 4 // 衣柜分享包 + GoodsTypeOutfitShare int8 = 5 // 搭配分享包 + + GoodsStatusOnShelf int8 = 1 // 上架 + GoodsStatusOffShelf int8 = 2 // 下架 + + // 虚拟支付道具发布状态 + GoodsProductUnpublished int8 = 0 // 未发布 + GoodsProductPublishing int8 = 1 // 发布中(上传/发布任务处理中) + GoodsProductPublished int8 = 2 // 已发布 + GoodsProductPublishFail int8 = 3 // 发布失败 +) + +// ===== 订单 Order ===== +const ( + OrderStatusPending int8 = 1 // 待支付 + OrderStatusPaid int8 = 2 // 已支付 + OrderStatusCancelled int8 = 3 // 已取消 + OrderStatusExpired int8 = 4 // 已过期 + OrderStatusRefunded int8 = 5 // 已退款 + + PayChannelWechat int8 = 1 // 微信支付(普通 JSAPI) + PayChannelVirtual int8 = 2 // 微信虚拟支付(XPay) + + // 虚拟支付平台(由前端按 systemInfo.platform 上报) + XPayPlatformAndroid = "android" + XPayPlatformIOS = "ios" + XPayPlatformHarmony = "harmony" + XPayPlatformWindows = "windows" +) + +// ===== 退款 Refund ===== +const ( + RefundStatusPending int8 = 0 // 待处理 + RefundStatusProcessing int8 = 1 // 退款中 + RefundStatusSuccess int8 = 2 // 退款成功 + RefundStatusFailed int8 = 3 // 退款失败 +) + +// ===== VIP 流水 ===== +const ( + VIPChangeTypeOpen int8 = 1 // 开通 + VIPChangeTypeRenew int8 = 2 // 续费 + VIPChangeTypeActivity int8 = 3 // 活动赠送 + VIPChangeTypeExpire int8 = 4 // 到期失效 + VIPChangeTypeManual int8 = 5 // 人工调整 + VIPChangeTypeRefundRevoke int8 = 6 // 退款回收 + + VIPSourcePersonalPay int8 = 1 // 个人支付 + VIPSourceActivity int8 = 2 // 活动赠送 + VIPSourceSystem int8 = 3 // 系统 + VIPSourceManual int8 = 4 // 人工调整 +) + +// ===== AI 流水状态 ===== +const ( + AIFlowStatusSuccess int8 = 1 // 成功 + AIFlowStatusFailed int8 = 2 // 失败 +) + +// 订单支付有效期(秒)。 +const orderPayTTLSeconds int64 = 15 * 60 + +// ===== 权益发放补偿 BenefitGrantTask ===== +const ( + BenefitTypeVIP int8 = 1 // 会员 + BenefitTypeCos int8 = 2 // 上传额度 + BenefitTypeAI int8 = 3 // AI 推荐额度 + BenefitTypeClosetShare int8 = 4 // 衣柜分享额度 + BenefitTypeOutfitShare int8 = 5 // 搭配分享额度 + + BenefitTaskPending int8 = 0 // 待发放 + BenefitTaskSuccess int8 = 1 // 已发放 + BenefitTaskFailed int8 = 2 // 发放失败(重试超限) + BenefitTaskProcessing int8 = 3 // 发放中(认领后,防并发重复发放) + + benefitMaxRetry int = 3 // 默认最大重试次数 + + // benefitRetryCooldownSeconds 失败任务被回调重新触发的冷却期(秒): + // 冷却期内不重置,防止微信回调重试风暴无限重置任务绕过重试上限。 + benefitRetryCooldownSeconds int64 = 300 +) diff --git a/internal/service/goods.go b/internal/service/goods.go new file mode 100644 index 0000000..939ce51 --- /dev/null +++ b/internal/service/goods.go @@ -0,0 +1,84 @@ +package service + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" +) + +// GoodsService 商品业务服务。 +type GoodsService struct { + goodsDao *mysqldao.GoodsDao +} + +// NewGoodsService 创建商品服务。 +func NewGoodsService() *GoodsService { + return &GoodsService{ + goodsDao: mysqldao.NewGoodsDao(), + } +} + +// GetByCode 按商品编码查询商品(下架返回错误)。 +func (s *GoodsService) GetByCode(ctx context.Context, request *dto.GoodsGetByCodeRequest) (*dto.GoodsGetByCodeResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.GoodsGetByCode") + defer span.End() + + goods, err := s.goodsDao.GoodsGetByCode(ctx, request.GoodsCode) + if err != nil { + if isNotFound(err) { + return nil, errcode.GoodsNotFound + } + return nil, errcode.DatabaseError + } + if goods.Status != GoodsStatusOnShelf { + return nil, errcode.GoodsOffShelf + } + response := buildGoodsResponse(goods) + return &response, nil +} + +// List 分页查询上架商品(按类型过滤)。 +func (s *GoodsService) List(ctx context.Context, request *dto.GoodsListRequest) (*dto.GoodsListResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.GoodsList") + defer span.End() + + page, pageSize := normalizePage(request.Page, request.PageSize) + + list, total, err := s.goodsDao.GoodsList(ctx, request.GoodsType, page, pageSize) + if err != nil { + return nil, errcode.DatabaseError + } + + items := make([]dto.GoodsGetByCodeResponse, 0, len(list)) + for i := range list { + items = append(items, buildGoodsResponse(&list[i])) + } + + return &dto.GoodsListResponse{ + PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, + List: items, + }, nil +} + +// buildGoodsResponse 将商品模型映射为响应。注意 dto.UploadQuota 对应 model.CosQuota。 +func buildGoodsResponse(g *mysqlmodel.Goods) dto.GoodsGetByCodeResponse { + return dto.GoodsGetByCodeResponse{ + ID: g.ID, + GoodsCode: g.GoodsCode, + GoodsName: g.GoodsName, + GoodsType: g.GoodsType, + Price: g.Price, + DiscountPrice: g.DiscountPrice, + MemberDays: g.MemberDays, + UploadQuota: g.CosQuota, + AIRecommendQuota: g.AIRecommendQuota, + ClosetShareQuota: g.ClosetShareQuota, + OutfitShareQuota: g.OutfitShareQuota, + ValidDays: g.ValidDays, + Description: g.Description, + } +} diff --git a/internal/service/horoscope.go b/internal/service/horoscope.go new file mode 100644 index 0000000..86298e4 --- /dev/null +++ b/internal/service/horoscope.go @@ -0,0 +1,172 @@ +package service + +import ( + "context" + "encoding/json" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub" +) + +// HoroscopeService 今日运势业务服务。 +type HoroscopeService struct { + horoscopeDao *mysqldao.HoroscopeDao + userDao *mysqldao.UserDao +} + +// NewHoroscopeService 创建运势服务。 +func NewHoroscopeService() *HoroscopeService { + return &HoroscopeService{ + horoscopeDao: mysqldao.NewHoroscopeDao(), + userDao: mysqldao.NewUserDao(), + } +} + +// GetToday 获取当前用户今日运势:由生日推算星座,Redis 优先、未命中回落 MySQL 并回填缓存。 +func (s *HoroscopeService) GetToday(ctx context.Context, userID int64) (*dto.HoroscopeResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.HoroscopeGetToday") + defer span.End() + + user, err := s.userDao.UserGetByID(ctx, userID) + if err != nil { + if isNotFound(err) { + return nil, errcode.UserNotFound + } + return nil, errcode.DatabaseError + } + if user.BirthDay == 0 { + return nil, errcode.InvalidParams.WithDetails("请先完善生日信息") + } + + zodiac := zodiacByUnix(user.BirthDay) + statDate := todayStatDate() + + // 1. Redis 优先。 + if data, cerr := redisdao.HoroscopeGet(ctx, zodiac, statDate); cerr == nil && len(data) > 0 { + var response dto.HoroscopeResponse + if json.Unmarshal(data, &response) == nil { + return &response, nil + } + } + + // 2. 回落 MySQL。 + h, derr := s.horoscopeDao.HoroscopeGetByZodiacAndDate(ctx, zodiac, statDate) + if derr != nil { + if isNotFound(derr) { + // 今日运势尚未生成,异步触发兜底生成,本次返回未生成。 + tokenhub.EnsureTodayFortunes() + return nil, errcode.HoroscopeNotFound + } + return nil, errcode.DatabaseError + } + + response := buildHoroscopeResponse(h) + + // 回填缓存,TTL 至次日凌晨。 + if data, merr := json.Marshal(response); merr == nil { + _ = redisdao.HoroscopeSet(ctx, zodiac, statDate, data, ttlUntilNextMidnight()) + } + + return &response, nil +} + +// buildHoroscopeResponse 将运势模型映射为响应。 +func buildHoroscopeResponse(h *mysqlmodel.Horoscope) dto.HoroscopeResponse { + return dto.HoroscopeResponse{ + StatDate: h.StatDate, + Zodiac: h.Zodiac, + OverallScore: h.OverallScore, + LoveScore: h.LoveScore, + CareerScore: h.CareerScore, + WealthScore: h.WealthScore, + DailyText: h.DailyText, + LuckyColor: h.LuckyColor, + LuckyNumber: h.LuckyNumber, + } +} + +// ttlUntilNextMidnight 计算到次日 0 点的时长,用于运势缓存过期。 +func ttlUntilNextMidnight() time.Duration { + now := time.Now() + next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) + return next.Sub(now) +} + +// zodiacByUnix 根据生日(秒级 unix 时间戳)计算星座(中文名,与运势数据对齐)。 +func zodiacByUnix(birthUnix int64) string { + t := time.Unix(birthUnix, 0) + return zodiacByMonthDay(int(t.Month()), t.Day()) +} + +// zodiacByMonthDay 根据月日返回星座中文名。 +func zodiacByMonthDay(month, day int) string { + switch month { + case 1: + if day <= 19 { + return "摩羯座" + } + return "水瓶座" + case 2: + if day <= 18 { + return "水瓶座" + } + return "双鱼座" + case 3: + if day <= 20 { + return "双鱼座" + } + return "白羊座" + case 4: + if day <= 19 { + return "白羊座" + } + return "金牛座" + case 5: + if day <= 20 { + return "金牛座" + } + return "双子座" + case 6: + if day <= 21 { + return "双子座" + } + return "巨蟹座" + case 7: + if day <= 22 { + return "巨蟹座" + } + return "狮子座" + case 8: + if day <= 22 { + return "狮子座" + } + return "处女座" + case 9: + if day <= 22 { + return "处女座" + } + return "天秤座" + case 10: + if day <= 23 { + return "天秤座" + } + return "天蝎座" + case 11: + if day <= 22 { + return "天蝎座" + } + return "射手座" + case 12: + if day <= 21 { + return "射手座" + } + return "摩羯座" + } + return "摩羯座" +} diff --git a/internal/service/like.go b/internal/service/like.go new file mode 100644 index 0000000..e835178 --- /dev/null +++ b/internal/service/like.go @@ -0,0 +1,125 @@ +package service + +import ( + "context" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" +) + +// LikeService 点赞业务服务。 +type LikeService struct { + likeDao *mysqldao.LikeDao + outfitDao *mysqldao.OutfitDao +} + +// NewLikeService 创建点赞服务。 +func NewLikeService() *LikeService { + return &LikeService{ + likeDao: mysqldao.NewLikeDao(), + outfitDao: mysqldao.NewOutfitDao(), + } +} + +// LikeCreate 点赞或取消点赞社区穿搭。 +func (s *LikeService) LikeCreate(ctx context.Context, userID int64, request *dto.LikeCreateRequest) (*dto.LikeResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.LikeLikeCreate") + defer span.End() + + outfit, cerr := s.getPublicOutfit(ctx, userID, request.TargetUUID) + if cerr != nil { + return nil, cerr + } + targetID := outfit.ID + + existing, err := s.likeDao.LikeGetByTargetAndUser(ctx, LikeTypeOutfit, targetID, userID) + notExist := false + if err != nil { + if isNotFound(err) { + notExist = true + } else { + return nil, errcode.DatabaseError + } + } + + switch request.Action { + case ActionDo: // 点赞 + if cerr := s.likeDao.LikeUpsertOn(ctx, &mysqlmodel.Like{ + LikeType: LikeTypeOutfit, + TargetID: targetID, + UserID: userID, + Status: InteractStatusOn, + }); cerr != nil { + return nil, errcode.LikeFailed + } + _ = redisdao.LikeAdd(ctx, LikeTypeOutfit, targetID, userID) + + // 仅在「从取消/无记录变为点赞」时发通知,避免并发/重复点赞重复通知。 + wasLiked := !notExist && existing.Status == InteractStatusOn + if !wasLiked && outfit.UserID != userID { + sendNotification(ctx, outfit.UserID, userID, NotificationTypeLike, "收到新的点赞", "你的穿搭「"+outfit.OutfitName+"」收到了一个赞") + } + + case ActionCancel: // 取消点赞 + if !notExist && existing.Status == InteractStatusOn { + if uerr := s.likeDao.LikeUpdateStatus(ctx, existing.ID, InteractStatusOff); uerr != nil { + return nil, errcode.LikeFailed + } + _ = redisdao.LikeRemove(ctx, LikeTypeOutfit, targetID, userID) + } + } + + return &dto.LikeResponse{}, nil +} + +// GetByTargetID 查询穿搭点赞状态与点赞数(Redis 优先)。 +func (s *LikeService) GetByTargetID(ctx context.Context, userID int64, request *dto.LikeGetByTargetIDRequest) (*dto.LikeGetByTargetIDResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.LikeGetByTargetID") + defer span.End() + + outfit, cerr := s.getPublicOutfit(ctx, userID, request.TargetUUID) + if cerr != nil { + return nil, cerr + } + targetID := outfit.ID + + // 是否已点赞:Redis 优先,回落 MySQL。 + isLiked, err := redisdao.LikeIsMember(ctx, LikeTypeOutfit, targetID, userID) + if err != nil { + if rec, derr := s.likeDao.LikeGetByTargetAndUser(ctx, LikeTypeOutfit, targetID, userID); derr == nil { + isLiked = rec.Status == InteractStatusOn + } + } + + // 点赞总数:Redis 优先,回落 MySQL。 + count, err := redisdao.LikeCount(ctx, LikeTypeOutfit, targetID) + if err != nil || count == 0 { + if dbCnt, derr := s.likeDao.LikeCountByTarget(ctx, LikeTypeOutfit, targetID); derr == nil { + count = dbCnt + } + } + + return &dto.LikeGetByTargetIDResponse{ + UserIsLiked: isLiked, + LikeCount: count, + }, nil +} + +// getPublicOutfit 按 UUID 查询社区穿搭(须存在),非本人仅可操作公开可见的穿搭。 +func (s *LikeService) getPublicOutfit(ctx context.Context, userID int64, outfitUUID string) (*mysqlmodel.Outfit, *errcode.Error) { + outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID) + if err != nil { + if isNotFound(err) { + return nil, errcode.TargetNotFound + } + return nil, errcode.DatabaseError + } + if outfit.UserID != userID && !isOutfitPublicVisible(outfit) { + return nil, errcode.OutfitNoPermission + } + return outfit, nil +} diff --git a/internal/service/notification.go b/internal/service/notification.go new file mode 100644 index 0000000..93e5c8a --- /dev/null +++ b/internal/service/notification.go @@ -0,0 +1,158 @@ +package service + +import ( + "context" + "log/slog" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" +) + +// NotificationService 通知业务服务。 +type NotificationService struct { + notificationDao *mysqldao.NotificationDao +} + +// NewNotificationService 创建通知服务。 +func NewNotificationService() *NotificationService { + return &NotificationService{ + notificationDao: mysqldao.NewNotificationDao(), + } +} + +// List 分页查询当前用户通知。 +func (s *NotificationService) List(ctx context.Context, userID int64, request *dto.NotificationListRequest) (*dto.NotificationListResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.NotificationList") + defer span.End() + + page, pageSize := normalizePage(request.Page, request.PageSize) + + list, total, err := s.notificationDao.NotificationListByUserID(ctx, userID, request.IsRead, request.MsgType, page, pageSize) + if err != nil { + return nil, errcode.DatabaseError + } + + items := make([]dto.NotificationGetByUUIDResponse, 0, len(list)) + for i := range list { + items = append(items, buildNotificationResponse(&list[i])) + } + + return &dto.NotificationListResponse{ + PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, + List: items, + }, nil +} + +// GetByUUID 获取通知详情(校验归属)。 +func (s *NotificationService) GetByUUID(ctx context.Context, userID int64, request *dto.NotificationGetByUUIDRequest) (*dto.NotificationGetByUUIDResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.NotificationGetByUUID") + defer span.End() + + n, err := s.notificationDao.NotificationGetByUUID(ctx, request.NotificationUUID) + if err != nil { + if isNotFound(err) { + return nil, errcode.NotificationNotFound + } + return nil, errcode.DatabaseError + } + if n.UserID != userID { + return nil, errcode.Forbidden + } + response := buildNotificationResponse(n) + return &response, nil +} + +// Read 标记通知已读(校验归属),并同步扣减未读数。 +func (s *NotificationService) Read(ctx context.Context, userID int64, request *dto.NotificationReadRequest) (*dto.NotificationUnreadResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.NotificationRead") + defer span.End() + + n, err := s.notificationDao.NotificationGetByUUID(ctx, request.NotificationUUID) + if err != nil { + if isNotFound(err) { + return nil, errcode.NotificationNotFound + } + return nil, errcode.DatabaseError + } + if n.UserID != userID { + return nil, errcode.Forbidden + } + + // 已读则直接返回当前未读数。 + if n.IsRead == NotificationRead { + cnt, _ := redisdao.NotificationUnreadGet(ctx, userID) + return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil + } + + // 条件标记已读(仅未读 -> 已读),防并发重复扣减未读数。 + ok, err := s.notificationDao.NotificationUpdateReadByUUIDIfUnread(ctx, request.NotificationUUID) + if err != nil { + return nil, errcode.DatabaseError + } + if !ok { + // 已被并发标记已读,直接返回当前未读数。 + cnt, _ := redisdao.NotificationUnreadGet(ctx, userID) + return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil + } + cnt, _ := redisdao.NotificationUnreadDecr(ctx, userID) + + return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil +} + +// Unread 获取当前用户未读数,Redis 优先、回落 MySQL。 +func (s *NotificationService) Unread(ctx context.Context, userID int64) (*dto.NotificationUnreadResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.NotificationUnread") + defer span.End() + + cnt, err := redisdao.NotificationUnreadGet(ctx, userID) + if err == nil && cnt > 0 { + return &dto.NotificationUnreadResponse{UnreadCount: cnt}, nil + } + + // Redis 为 0 或异常,回落 MySQL 统计。 + dbCnt, derr := s.notificationDao.NotificationCountUnread(ctx, userID) + if derr != nil { + return nil, errcode.DatabaseError + } + // 回填 Redis 未读数缓存,避免 key 过期导致后续标记已读时扣减被吞。 + _ = redisdao.NotificationUnreadSet(ctx, userID, dbCnt) + return &dto.NotificationUnreadResponse{UnreadCount: dbCnt}, nil +} + +// buildNotificationResponse 将通知模型映射为响应。 +func buildNotificationResponse(n *mysqlmodel.UserNotification) dto.NotificationGetByUUIDResponse { + return dto.NotificationGetByUUIDResponse{ + NotificationUUID: n.NotificationUUID, + MsgType: n.MsgType, + Title: n.Title, + Content: n.Content, + IsRead: n.IsRead, + CreatedAt: n.CreatedAt, + } +} + +// sendNotification 发送一条通知并同步递增接收方未读数(供 like/order 等复用,best-effort)。 +func sendNotification(ctx context.Context, recipientUserID, senderID int64, msgType int8, title, content string) { + dao := mysqldao.NewNotificationDao() + n := &mysqlmodel.UserNotification{ + NotificationUUID: genUUID(), + UserID: recipientUserID, + MsgType: msgType, + SenderID: senderID, + Title: title, + Content: content, + IsRead: NotificationUnread, + } + if err := dao.NotificationCreate(ctx, n); err != nil { + slog.WarnContext(ctx, "create notification failed", + "user_id", recipientUserID, "msg_type", msgType, "error", err) + return + } + if _, err := redisdao.NotificationUnreadIncr(ctx, recipientUserID); err != nil { + slog.ErrorContext(ctx, "notification unread incr failed", "user_id", recipientUserID, "error", err) + } +} diff --git a/internal/service/order.go b/internal/service/order.go new file mode 100644 index 0000000..85e8f04 --- /dev/null +++ b/internal/service/order.go @@ -0,0 +1,747 @@ +package service + +import ( + "context" + "log/slog" + "net/http" + "strings" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat" + "gorm.io/gorm" +) + +// OrderService 订单与退款业务服务。 +type OrderService struct { + orderDao *mysqldao.OrderDao + goodsDao *mysqldao.GoodsDao + userDao *mysqldao.UserDao + refundDao *mysqldao.RefundFlowLogDao + vipDao *mysqldao.VIPFlowLogDao +} + +// NewOrderService 创建订单服务。 +func NewOrderService() *OrderService { + return &OrderService{ + orderDao: mysqldao.NewOrderDao(), + goodsDao: mysqldao.NewGoodsDao(), + userDao: mysqldao.NewUserDao(), + refundDao: mysqldao.NewRefundFlowLogDao(), + vipDao: mysqldao.NewVIPFlowLogDao(), + } +} + +// Create 创建订单并发起微信 JSAPI 下单。 +func (s *OrderService) Create(ctx context.Context, userID int64, request *dto.OrderCreateRequest) (*dto.OrderCreateResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.OrderCreate") + defer span.End() + + goods, err := s.goodsDao.GoodsGetByCode(ctx, request.GoodsCode) + if err != nil { + if isNotFound(err) { + return nil, errcode.GoodsNotFound + } + return nil, errcode.DatabaseError + } + if goods.Status != GoodsStatusOnShelf { + return nil, errcode.GoodsOffShelf + } + + // 支付用户 openid。 + user, uerr := s.userDao.UserGetByID(ctx, userID) + if uerr != nil { + if isNotFound(uerr) { + return nil, errcode.UserNotFound + } + return nil, errcode.DatabaseError + } + if user.WxOpenID == "" { + return nil, errcode.OrderCreateFailed.WithDetails("缺少微信 openid") + } + + orderNo := genOrderNo() + expireTime := nowUnix() + orderPayTTLSeconds + payAmount := goods.DiscountPrice + + order := &mysqlmodel.Order{ + OrderNo: orderNo, + UserID: userID, + GoodsID: goods.ID, + GoodsName: goods.GoodsName, + UnitPrice: goods.DiscountPrice, + TotalAmount: payAmount, + PayAmount: payAmount, + PayChannel: PayChannelWechat, + OrderStatus: OrderStatusPending, + MemberDays: goods.MemberDays, + CosQuota: goods.CosQuota, + AIRecommendQuota: goods.AIRecommendQuota, + ClosetShareQuota: goods.ClosetShareQuota, + OutfitShareQuota: goods.OutfitShareQuota, + ExpireTime: expireTime, + } + // 先落库再微信下单:微信下单失败时本地订单保留为待支付并超时自动过期, + // 避免「先微信下单、落库失败」产生无本地记录的孤儿预支付单。 + if cerr := s.orderDao.OrderCreate(ctx, order); cerr != nil { + return nil, errcode.OrderCreateFailed + } + + payParams, perr := wechat.JsapiPrepay(ctx, user.WxOpenID, orderNo, goods.GoodsName, payAmount) + if perr != nil { + return nil, errcode.OrderCreateFailed + } + + return &dto.OrderCreateResponse{ + OrderNo: orderNo, + PayAmount: payAmount, + ExpireTime: expireTime, + PayParams: &dto.WechatPayParams{ + TimeStamp: payParams.TimeStamp, + NonceStr: payParams.NonceStr, + Package: payParams.Package, + SignType: payParams.SignType, + PaySign: payParams.PaySign, + }, + }, nil +} + +// PayNotify 处理微信支付结果回调(供回调 handler 调用):验签解密 + 幂等 + 发放权益。 +func (s *OrderService) PayNotify(ctx context.Context, request *http.Request) *errcode.Error { + ctx, span := global.Tracer.Start(ctx, "svc.OrderPayNotify") + defer span.End() + + data, err := wechat.VerifyAndDecryptPayNotify(ctx, request) + if err != nil { + return errcode.RPCError + } + if data.TradeState != "SUCCESS" { + return nil // 非支付成功,忽略。 + } + + // 幂等锁。 + lockKey := fmtLockKey(redismodel.KeyLockPayCallback, data.OutTradeNo) + lockVal := genUUID() + if ok, _ := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second); !ok { + return nil // 并发回调,交由持锁者处理。 + } + defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }() + + // 先查订单并校验回调金额,防止金额被篡改导致错误发放。 + order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo) + if gerr != nil { + if isNotFound(gerr) { + slog.ErrorContext(ctx, "pay notify: order not found", "out_trade_no", data.OutTradeNo) + return nil + } + return errcode.DatabaseError + } + if data.Amount.Total != order.PayAmount { + slog.ErrorContext(ctx, "pay notify: amount mismatch", + "out_trade_no", data.OutTradeNo, + "notify_amount", data.Amount.Total, + "order_amount", order.PayAmount, + ) + return nil // 金额不一致,不流转、不发放权益,人工介入。 + } + + // 条件流转:待支付 -> 已支付,防重复回调。 + updated, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, data.OutTradeNo, OrderStatusPending, OrderStatusPaid) + if uerr != nil { + return errcode.DatabaseError + } + if updated { + s.finalizePaidOrder(ctx, order, data) + return nil + } + + // 流转失败:重新读取订单,处理「本地已过期/已取消但用户实际已付款」的竞态。 + current, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo) + if gerr != nil { + if isNotFound(gerr) { + return nil + } + return errcode.DatabaseError + } + + switch current.OrderStatus { + case OrderStatusPaid: + // 已被并发回调处理。兜底确保支付信息与权益发放已完成(均幂等), + // 覆盖「订单已流转为 Paid 但权益发放前崩溃导致权益丢失」的场景。 + _ = s.orderDao.OrderUpdateByOrderNo(ctx, current.OrderNo, map[string]any{ + "pay_time": nowUnix(), + "transaction_id": data.TransactionID, + "callback_raw": data.Raw, + }) + s.grantOrderBenefits(ctx, current) + return nil + case OrderStatusExpired: + // 订单被系统过期但用户已付款:过期 -> 已支付并补发货,避免用户付款却不到账。 + ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, data.OutTradeNo, OrderStatusExpired, OrderStatusPaid) + if uerr != nil { + return errcode.DatabaseError + } + if !ok { + return nil // 已被其它回调处理。 + } + s.finalizePaidOrder(ctx, current, data) + return nil + case OrderStatusCancelled: + // 用户主动取消却仍支付(关闭订单与支付存在竞态窗口):自动发起退款,避免用户付款却拿不到权益。 + s.autoRefundCancelledOrder(ctx, current, data) + return nil + default: + return nil + } +} + +// Cancel 取消待支付订单(校验归属)。 +func (s *OrderService) Cancel(ctx context.Context, userID int64, request *dto.OrderCancelRequest) (*dto.OrderResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.OrderCancel") + defer span.End() + + order, cerr := s.getOwnedOrder(ctx, request.OrderNo, userID) + if cerr != nil { + return nil, cerr + } + // 虚拟支付订单不可取消:虚拟支付无服务端关单 API,本地取消后微信侧单仍有效, + // 用户继续支付会造成「已付款但权益不发放」。禁止取消从源头杜绝该资损场景。 + if order.PayChannel == PayChannelVirtual { + return nil, errcode.VirtualOrderCancelNotAllowed + } + if order.OrderStatus == OrderStatusPaid { + return nil, errcode.OrderAlreadyPaid + } + if order.OrderStatus != OrderStatusPending { + return nil, errcode.OrderStatusInvalid + } + + ok, err := s.orderDao.OrderUpdateStatusByOrderNo(ctx, request.OrderNo, OrderStatusPending, OrderStatusCancelled) + if err != nil { + return nil, errcode.OrderCancelFailed + } + if !ok { + return nil, errcode.OrderStatusInvalid + } + + // 本地取消成功后,尽力关闭微信侧订单,防止用户继续支付(失败仅告警,不阻断取消)。 + if cerr := wechat.CloseOrder(ctx, request.OrderNo); cerr != nil { + slog.WarnContext(ctx, "close wechat order failed", "order_no", request.OrderNo, "error", cerr) + } + + order.OrderStatus = OrderStatusCancelled + response := buildOrderResponse(order) + return &response, nil +} + +// List 分页查询我的订单。 +func (s *OrderService) List(ctx context.Context, userID int64, request *dto.OrderListRequest) (*dto.OrderListResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.OrderList") + defer span.End() + + page, pageSize := normalizePage(request.Page, request.PageSize) + + list, total, err := s.orderDao.OrderListByUserID(ctx, userID, request.Status, page, pageSize) + if err != nil { + return nil, errcode.DatabaseError + } + + items := make([]dto.OrderResponse, 0, len(list)) + for i := range list { + items = append(items, buildOrderResponse(&list[i])) + } + + return &dto.OrderListResponse{ + PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, + List: items, + }, nil +} + +// RefundApply 申请退款(仅已支付订单)。 +func (s *OrderService) RefundApply(ctx context.Context, userID int64, request *dto.RefundApplyRequest) (*dto.RefundResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.OrderRefundApply") + defer span.End() + + order, cerr := s.getOwnedOrder(ctx, request.OrderNo, userID) + if cerr != nil { + return nil, cerr + } + // 虚拟支付 iOS 订单不可主动退款:用户走 App Store 申请,Apple 通过退款问询推送回收。 + if order.PayChannel == PayChannelVirtual && order.XPayPlatform == XPayPlatformIOS { + return nil, errcode.IOSRefundViaAppStore + } + if order.OrderStatus != OrderStatusPaid { + return nil, errcode.RefundNotAllowed + } + + // 已有退款流水则按状态处理:非失败不允许重复申请;失败则复用旧记录重新申请。 + existing, gerr := s.refundDao.RefundFlowLogGetByOrderNo(ctx, request.OrderNo) + if gerr != nil && !isNotFound(gerr) { + return nil, errcode.DatabaseError + } + if existing != nil && existing.Status != RefundStatusFailed { + return nil, errcode.RefundNotAllowed + } + + refundNo := genRefundNo() + applyTime := nowUnix() + + if existing != nil { + // 失败重试:复用旧记录,更新退款单号与状态。 + if err := s.refundDao.RefundFlowLogUpdateStatus(ctx, existing.RefundNo, map[string]any{ + "refund_no": refundNo, + "refund_amount": order.PayAmount, + "refund_reason": request.Reason, + "status": RefundStatusPending, + "apply_time": applyTime, + "fail_reason": "", + }); err != nil { + return nil, errcode.RefundApplyFailed + } + } else { + // 新建退款流水(order_no 唯一索引兜底并发,冲突即已有申请)。 + flow := &mysqlmodel.RefundFlowLog{ + OrderID: order.ID, + OrderNo: order.OrderNo, + UserID: userID, + RefundNo: refundNo, + RefundAmount: order.PayAmount, + RefundReason: request.Reason, + Status: RefundStatusPending, + ApplyTime: applyTime, + } + if err := s.refundDao.RefundFlowLogCreate(ctx, flow); err != nil { + if isDuplicateKey(err) { + return nil, errcode.RefundNotAllowed + } + return nil, errcode.RefundApplyFailed + } + } + + // 人工审核:不再自动调用微信退款,仅创建待审核退款申请,避免自动退款造成权益未回退的资损。 + // 运营审核通过后在微信后台手动发起退款,微信退款回调 RefundNotify 会更新订单与流水状态。 + return &dto.RefundResponse{ + RefundNo: refundNo, + RefundAmount: order.PayAmount, + Status: RefundStatusPending, + ApplyTime: applyTime, + }, nil +} + +// RefundNotify 处理微信退款结果回调(供回调 handler 调用):验签解密 + 幂等 + 更新状态。 +func (s *OrderService) RefundNotify(ctx context.Context, request *http.Request) *errcode.Error { + ctx, span := global.Tracer.Start(ctx, "svc.OrderRefundNotify") + defer span.End() + + data, err := wechat.VerifyAndDecryptRefundNotify(ctx, request) + if err != nil { + return errcode.RPCError + } + + lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, data.OutRefundNo) + lockVal := genUUID() + if ok, _ := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second); !ok { + return nil + } + defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }() + + flow, ferr := s.refundDao.RefundFlowLogGetByRefundNo(ctx, data.OutRefundNo) + if ferr != nil { + if isNotFound(ferr) { + return nil + } + return errcode.DatabaseError + } + // 已终态则幂等返回。 + if flow.Status == RefundStatusSuccess || flow.Status == RefundStatusFailed { + return nil + } + + // 校验回调退款金额与流水一致,防止金额被篡改。 + if data.Amount.Refund != flow.RefundAmount { + slog.ErrorContext(ctx, "refund notify: amount mismatch", + "out_refund_no", data.OutRefundNo, + "notify_amount", data.Amount.Refund, + "flow_amount", flow.RefundAmount, + ) + return nil + } + + if data.RefundStatus == "SUCCESS" { + // 条件流转,防止并发/乱序回调覆盖状态。 + ok, _ := s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{ + "status": RefundStatusSuccess, + "success_time": nowUnix(), + "callback_raw": data.Raw, + }) + if !ok { + return nil // 已被并发回调处理。 + } + order, gerr := s.orderDao.OrderGetByOrderNo(ctx, data.OutTradeNo) + if gerr == nil { + s.settleRefundSuccess(ctx, order, flow.RefundAmount) + } + } else { + _, _ = s.refundDao.RefundFlowLogUpdateStatusByFrom(ctx, data.OutRefundNo, flow.Status, map[string]any{ + "status": RefundStatusFailed, + "fail_reason": "微信退款未成功:" + data.RefundStatus, + "callback_raw": data.Raw, + }) + } + + return nil +} + +// autoRefundCancelledOrder 订单已取消但用户已付款:生成待审核退款单,交由管理员在后台手动退款。 +// 不再自动调用微信退款(所有退款均由管理员操作),避免「自动退款失败后流水卡死无重试」的资损风险。 +func (s *OrderService) autoRefundCancelledOrder(ctx context.Context, order *mysqlmodel.Order, data *wechat.PayNotifyData) { + // 幂等:已有退款流水则跳过,避免重复生成待审核退款单。 + if existing, err := s.refundDao.RefundFlowLogGetByOrderNo(ctx, order.OrderNo); err == nil && existing != nil { + return + } + + // 补记支付信息(订单已取消,此前未记录),便于管理员审核退款时核对。 + _ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{ + "pay_time": nowUnix(), + "transaction_id": data.TransactionID, + "callback_raw": data.Raw, + }) + + refundNo := genRefundNo() + flow := &mysqlmodel.RefundFlowLog{ + OrderID: order.ID, + OrderNo: order.OrderNo, + UserID: order.UserID, + RefundNo: refundNo, + RefundAmount: order.PayAmount, + RefundReason: "订单已取消但已付款,待管理员退款", + Status: RefundStatusPending, + ApplyTime: nowUnix(), + } + if err := s.refundDao.RefundFlowLogCreate(ctx, flow); err != nil { + if isDuplicateKey(err) { + return + } + slog.ErrorContext(ctx, "auto refund: create refund flow failed", "order_no", order.OrderNo, "error", err) + return + } + slog.InfoContext(ctx, "cancelled order refund flow created, waiting manual review", + "order_no", order.OrderNo, "refund_no", refundNo) +} + +// ---- 内部辅助 ---- + +// getOwnedOrder 查询订单并校验归属。 +func (s *OrderService) getOwnedOrder(ctx context.Context, orderNo string, userID int64) (*mysqlmodel.Order, *errcode.Error) { + order, err := s.orderDao.OrderGetByOrderNo(ctx, orderNo) + if err != nil { + if isNotFound(err) { + return nil, errcode.OrderNotFound + } + return nil, errcode.DatabaseError + } + if order.UserID != userID { + return nil, errcode.Forbidden + } + return order, nil +} + +// finalizePaidOrder 记录支付信息、发放权益并发送通知(支付成功或过期补发货后调用)。 +func (s *OrderService) finalizePaidOrder(ctx context.Context, order *mysqlmodel.Order, data *wechat.PayNotifyData) { + _ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{ + "pay_time": nowUnix(), + "transaction_id": data.TransactionID, + "callback_raw": data.Raw, + }) + + s.grantOrderBenefits(ctx, order) + + sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "订单支付成功", "你购买的「"+order.GoodsName+"」已到账") +} + +// grantOrderBenefits 支付成功后幂等发放会员天数与各类额度权益(使用下单时的订单快照,避免商品变更导致错发)。 +func (s *OrderService) grantOrderBenefits(ctx context.Context, order *mysqlmodel.Order) { + benefits := []struct { + benefitType int8 + amount int64 + }{ + {BenefitTypeVIP, order.MemberDays}, + {BenefitTypeCos, order.CosQuota}, + {BenefitTypeAI, order.AIRecommendQuota}, + {BenefitTypeClosetShare, order.ClosetShareQuota}, + {BenefitTypeOutfitShare, order.OutfitShareQuota}, + } + for _, b := range benefits { + if b.amount > 0 { + s.grantBenefitIdempotent(ctx, order, b.benefitType, b.amount) + } + } +} + +// grantBenefitIdempotent 幂等发放单个权益:先落补偿任务凭证(唯一索引 order_no+benefit_type), +// 凭证创建成功才执行发放;凭证冲突说明已有记录,按状态跳过或交定时任务处理。 +func (s *OrderService) grantBenefitIdempotent(ctx context.Context, order *mysqlmodel.Order, benefitType int8, amount int64) { + taskDao := mysqldao.NewBenefitGrantTaskDao() + task := &mysqlmodel.BenefitGrantTask{ + UserID: order.UserID, + OrderNo: order.OrderNo, + GoodsID: order.GoodsID, + BenefitType: benefitType, + Amount: amount, + Status: BenefitTaskPending, + MaxRetry: benefitMaxRetry, + } + if err := taskDao.BenefitGrantTaskCreate(ctx, task); err != nil { + // 非唯一冲突(DB 临时故障等):记录错误返回,交由补偿/人工,不能静默丢权益。 + if !isDuplicateKey(err) { + slog.ErrorContext(ctx, "create benefit grant task failed", + "order_no", order.OrderNo, "benefit_type", benefitType, "error", err) + return + } + // 唯一冲突:已有记录。 + existing, gerr := taskDao.BenefitGrantTaskGetByOrderNoAndType(ctx, order.OrderNo, benefitType) + if gerr != nil { + slog.ErrorContext(ctx, "get benefit grant task failed", + "order_no", order.OrderNo, "benefit_type", benefitType, "error", gerr) + return + } + if existing.Status == BenefitTaskSuccess { + return // 已发放成功。 + } + if existing.Status == BenefitTaskFailed { + // 曾失败过:冷却期内不重置(防微信回调重试风暴无限重置任务绕过重试上限), + // 冷却期外重置为待发放并尝试发放(发放本身有幂等兜底,重置失败仅告警)。 + if nowUnix()-existing.UpdatedAt < benefitRetryCooldownSeconds { + slog.WarnContext(ctx, "benefit grant task in cooldown, skip retry", + "order_no", order.OrderNo, "benefit_type", benefitType, "task_id", existing.ID) + return + } + if rerr := taskDao.BenefitGrantTaskReset(ctx, existing.ID); rerr != nil { + slog.WarnContext(ctx, "reset benefit grant task failed", + "order_no", order.OrderNo, "benefit_type", benefitType, "error", rerr) + return + } + s.executeGrant(ctx, existing) + } + return // pending 交由定时任务处理。 + } + + // 凭证创建成功,执行发放(claim 防并发)。 + s.executeGrant(ctx, task) +} + +// executeGrant 认领并发放单个权益任务(claim 保证同一任务仅发放一次,防并发重复发放)。 +func (s *OrderService) executeGrant(ctx context.Context, task *mysqlmodel.BenefitGrantTask) { + taskDao := mysqldao.NewBenefitGrantTaskDao() + claimed, err := taskDao.BenefitGrantTaskClaim(ctx, task.ID) + if err != nil || !claimed { + return // 已被其他实例处理。 + } + if e := s.doGrantBenefit(ctx, task); e != nil { + // 发放失败:回退为待发放并记录错误,交由补偿任务重试。 + _ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{ + "status": BenefitTaskPending, + "retry_count": gorm.Expr("retry_count + 1"), + "last_error": e.Error(), + }) + slog.ErrorContext(ctx, "grant benefit failed", + "order_no", task.OrderNo, "benefit_type", task.BenefitType, "error", e) + return + } + _ = taskDao.BenefitGrantTaskUpdateByID(ctx, task.ID, map[string]any{"status": BenefitTaskSuccess}) +} + +// doGrantBenefit 根据补偿任务执行实际权益发放:VIP 实际发放,额度类写入有有效期凭证。 +func (s *OrderService) doGrantBenefit(ctx context.Context, task *mysqlmodel.BenefitGrantTask) *errcode.Error { + switch task.BenefitType { + case BenefitTypeVIP: + if err := s.grantVIP(ctx, task.UserID, task.Amount, task.OrderNo); err != nil { + return errcode.DatabaseError + } + return nil + case BenefitTypeCos: + return s.grantQuotaGrant(ctx, task, QuotaTypeCos) + case BenefitTypeAI: + return s.grantQuotaGrant(ctx, task, QuotaTypeAI) + case BenefitTypeClosetShare: + return s.grantQuotaGrant(ctx, task, QuotaTypeClosetShare) + case BenefitTypeOutfitShare: + return s.grantQuotaGrant(ctx, task, QuotaTypeOutfitShare) + default: + return errcode.DatabaseError + } +} + +// grantQuotaGrant 发放额度凭证(替代原 grantQuota 累加 user_quotas.total)。 +// 会员包(goods_type=1)凭证跟随 VIP(expire_time=0);加量包按商品 valid_days 设置独立到期时间。 +// ref_id 复用订单号,配合 (ref_id, quota_type) 唯一索引实现幂等。 +func (s *OrderService) grantQuotaGrant(ctx context.Context, task *mysqlmodel.BenefitGrantTask, quotaType int8) *errcode.Error { + source := QuotaGrantSourceMember + expireTime := int64(0) // 0 表示跟随 VIP + + if goods, err := s.goodsDao.GoodsGetByID(ctx, task.GoodsID); err == nil && goods.GoodsType != GoodsTypeMember { + // 加量包:独立有效期,从发放时刻起算 valid_days 天;未配置则兜底 30 天。 + source = QuotaGrantSourceAddon + days := goods.ValidDays + if days <= 0 { + days = 30 + } + expireTime = nowUnix() + days*86400 + } + + grant := &mysqlmodel.QuotaGrant{ + UserID: task.UserID, + QuotaType: quotaType, + Amount: task.Amount, + Used: 0, + Source: source, + ExpireTime: expireTime, + RefID: task.OrderNo, + } + if err := mysqldao.NewQuotaGrantDao().QuotaGrantCreate(ctx, grant); err != nil { + if isDuplicateKey(err) { + return nil // 已发放,幂等。 + } + return errcode.DatabaseError + } + return nil +} + +// grantVIP 开通/续费会员,并写 VIP 流水(事务内幂等:累加与流水同事务,流水唯一索引兜底)。 +func (s *OrderService) grantVIP(ctx context.Context, userID, memberDays int64, orderNo string) error { + user, err := s.userDao.UserGetByID(ctx, userID) + if err != nil { + return err + } + + now := nowUnix() + + newVIPType := VIPTypeMonthly + if memberDays >= 365 { + newVIPType = VIPTypeYearly + } + + changeType := VIPChangeTypeOpen + if user.VIPType != VIPTypeNone && user.VIPExpire > now { + changeType = VIPChangeTypeRenew + } + + flow := &mysqlmodel.VIPFlowLog{ + UserID: userID, + ChangeType: changeType, + OldVIPType: user.VIPType, + NewVIPType: newVIPType, + OldVIPExpire: user.VIPExpire, + OrderNo: orderNo, + Source: VIPSourcePersonalPay, + Reason: "购买会员", + } + if err := s.userDao.VIPGrantIdempotent(ctx, userID, now, memberDays, newVIPType, flow); err != nil { + if isDuplicateKey(err) { + return nil // 已发放,幂等。 + } + return err + } + // 用户信息已变更,失效缓存。 + _ = redisdao.UserInfoDel(ctx, userID) + return nil +} + +// settleRefundSuccess 退款成功后将订单置为已退款、回退已发放权益并记录退款信息,发送通知。 +// 兼容两种退款来源:已支付订单(Paid->Refunded,需回收已发放权益)与已取消但已付款订单 +// (Cancelled->Refunded,其权益从未发放,跳过回收避免误扣其它来源的会员天数/额度凭证)。 +func (s *OrderService) settleRefundSuccess(ctx context.Context, order *mysqlmodel.Order, refundAmount int64) { + from := order.OrderStatus + if from != OrderStatusPaid && from != OrderStatusCancelled { + return + } + ok, _ := s.orderDao.OrderUpdateStatusByOrderNo(ctx, order.OrderNo, from, OrderStatusRefunded) + if !ok { + return + } + _ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{ + "refund_time": nowUnix(), + "refund_amount": refundAmount, + }) + // 仅已支付订单回退已发放权益;已取消订单权益从未发放,无需回收。 + if from == OrderStatusPaid { + s.revokeOrderBenefits(ctx, order) + } + sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "退款成功", "订单「"+order.GoodsName+"」已退款") +} + +// revokeOrderBenefits 退款成功后回退该订单发放的会员天数并失效赠送额度凭证。 +// 会员天数走 revokeVIP;额度类通过失效凭证回收(未用部分回收,已用部分不追回,凭证软删除后不计入有效额度)。 +func (s *OrderService) revokeOrderBenefits(ctx context.Context, order *mysqlmodel.Order) { + if order.MemberDays > 0 { + s.revokeVIP(ctx, order.UserID, order.MemberDays, order.OrderNo) + } + if err := mysqldao.NewQuotaGrantDao().QuotaGrantInvalidateByRefID(ctx, order.OrderNo); err != nil { + slog.WarnContext(ctx, "invalidate quota grants failed", "order_no", order.OrderNo, "error", err) + } +} + +// revokeVIP 幂等回退会员天数(退款回收)。 +func (s *OrderService) revokeVIP(ctx context.Context, userID, memberDays int64, orderNo string) { + user, err := s.userDao.UserGetByID(ctx, userID) + if err != nil { + slog.ErrorContext(ctx, "revoke vip: get user failed", "user_id", userID, "error", err) + return + } + flow := &mysqlmodel.VIPFlowLog{ + UserID: userID, + ChangeType: VIPChangeTypeRefundRevoke, + OldVIPType: user.VIPType, + NewVIPType: user.VIPType, + OldVIPExpire: user.VIPExpire, + OrderNo: orderNo, + Source: VIPSourceSystem, + Reason: "退款回收会员", + } + if err := s.userDao.VIPRevokeIdempotent(ctx, userID, nowUnix(), memberDays, flow); err != nil { + if !isDuplicateKey(err) { + slog.ErrorContext(ctx, "revoke vip failed", "order_no", orderNo, "error", err) + } + } + // 会员信息已变更,失效缓存。 + _ = redisdao.UserInfoDel(ctx, userID) +} + +// buildOrderResponse 将订单模型映射为响应。 +func buildOrderResponse(o *mysqlmodel.Order) dto.OrderResponse { + return dto.OrderResponse{ + OrderNo: o.OrderNo, + GoodsName: o.GoodsName, + UnitPrice: o.UnitPrice, + PayAmount: o.PayAmount, + PayChannel: o.PayChannel, + OrderStatus: o.OrderStatus, + MemberDays: o.MemberDays, + ExpireTime: o.ExpireTime, + PayTime: o.PayTime, + RefundTime: o.RefundTime, + RefundAmount: o.RefundAmount, + CreatedAt: o.CreatedAt, + } +} + +// genOrderNo 生成唯一商户订单号。 +func genOrderNo() string { + return "OD" + time.Now().Format("20060102150405") + strings.ReplaceAll(genUUID(), "-", "")[:16] +} + +// genRefundNo 生成唯一商户退款单号。 +func genRefundNo() string { + return "RF" + time.Now().Format("20060102150405") + strings.ReplaceAll(genUUID(), "-", "")[:16] +} + +// fmtLockKey 拼接分布式锁 key(key 模板含单个 %s 占位符)。 +func fmtLockKey(tmpl, arg string) string { + return strings.Replace(tmpl, "%s", arg, 1) +} diff --git a/internal/service/order_expire.go b/internal/service/order_expire.go new file mode 100644 index 0000000..71cefd7 --- /dev/null +++ b/internal/service/order_expire.go @@ -0,0 +1,46 @@ +package service + +import ( + "context" + "log/slog" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" +) + +// StartOrderExpireScheduler 启动订单超时过期定时任务:每分钟将超时未支付订单批量置为已过期。 +func StartOrderExpireScheduler() { + go func() { + slog.Info("order expire scheduler started") + for { + func() { + defer func() { + if r := recover(); r != nil { + slog.Error("order expire scheduler panic", "recover", r) + } + }() + runOrderExpire() + }() + time.Sleep(time.Minute) + } + }() +} + +// runOrderExpire 扫描并过期超时待支付订单。条件更新天然幂等,多实例并发无害,无需分布式锁。 +func runOrderExpire() { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + ctx, span := global.Tracer.Start(ctx, "scheduler.OrderExpire") + defer span.End() + + affected, err := mysqldao.NewOrderDao().OrderExpireByTime(ctx, nowUnix()) + if err != nil { + slog.ErrorContext(ctx, "expire orders failed", "error", err) + return + } + if affected > 0 { + slog.InfoContext(ctx, "expired pending orders", "count", affected) + } +} diff --git a/internal/service/outfit.go b/internal/service/outfit.go new file mode 100644 index 0000000..945f15f --- /dev/null +++ b/internal/service/outfit.go @@ -0,0 +1,424 @@ +package service + +import ( + "context" + "log/slog" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload" + "gorm.io/gorm" +) + +// OutfitService 穿搭业务服务。 +type OutfitService struct { + outfitDao *mysqldao.OutfitDao + outfitItemDao *mysqldao.OutfitItemDao + clothesDao *mysqldao.ClothesDao + auditLogDao *mysqldao.OutfitAuditLogDao +} + +// NewOutfitService 创建穿搭服务。 +func NewOutfitService() *OutfitService { + return &OutfitService{ + outfitDao: mysqldao.NewOutfitDao(), + outfitItemDao: mysqldao.NewOutfitItemDao(), + clothesDao: mysqldao.NewClothesDao(), + auditLogDao: mysqldao.NewOutfitAuditLogDao(), + } +} + +// Create 创建穿搭。 +func (s *OutfitService) Create(ctx context.Context, userID int64, request *dto.OutfitCreateRequest) (*dto.OutfitCreateResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.OutfitCreate") + defer span.End() + + clothesIDs, items, cerr := s.resolveOwnedClothes(ctx, userID, request.Items) + if cerr != nil { + return nil, cerr + } + + source := request.Source + if source != OutfitSourceUser && source != OutfitSourceAI { + source = OutfitSourceUser + } + + outfit := &mysqlmodel.Outfit{ + OutfitUUID: genUUID(), + UserID: userID, + OutfitName: request.OutfitName, + OutfitDesc: request.OutfitDesc, + Scene: request.Scene, + Source: source, + IsPublic: OutfitPrivate, + AuditStatus: OutfitAuditPending, + Status: OutfitStatusNormal, + } + if err := s.outfitDao.OutfitCreate(ctx, outfit); err != nil { + return nil, errcode.OutfitCreateFailed + } + + if err := s.createItems(ctx, outfit.ID, clothesIDs); err != nil { + return nil, errcode.OutfitCreateFailed + } + + response := buildOutfitCreateResponse(outfit, items) + return &response, nil +} + +// GetByUUID 获取穿搭详情。私有穿搭仅本人可见。 +func (s *OutfitService) GetByUUID(ctx context.Context, userID int64, request *dto.OutfitGetByUUIDRequest) (*dto.OutfitGetByUUIDResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.OutfitGetByUUID") + defer span.End() + + outfit, err := s.outfitDao.OutfitGetByUUID(ctx, request.OutfitUUID) + if err != nil { + if isNotFound(err) { + return nil, errcode.OutfitNotFound + } + return nil, errcode.DatabaseError + } + + // 可见性:本人,或已公开且审核通过且正常。 + if outfit.UserID != userID && !isOutfitPublicVisible(outfit) { + return nil, errcode.OutfitNoPermission + } + + items, lerr := s.itemResponses(ctx, outfit.ID) + if lerr != nil { + return nil, errcode.DatabaseError + } + + return &dto.OutfitGetByUUIDResponse{ + OutfitCreateResponse: buildOutfitCreateResponse(outfit, items), + }, nil +} + +// UpdateByUUID 修改穿搭基础信息与明细。 +func (s *OutfitService) UpdateByUUID(ctx context.Context, userID int64, request *dto.OutfitUpdateByUUIDRequest) (*dto.OutfitUpdateByUUIDResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.OutfitUpdateByUUID") + defer span.End() + + outfit, cerr := s.getOwnedOutfit(ctx, request.OutfitUUID, userID) + if cerr != nil { + return nil, cerr + } + + clothesIDs, _, verr := s.resolveOwnedClothes(ctx, userID, request.Items) + if verr != nil { + return nil, verr + } + + updates := map[string]any{} + setIfNotEmpty(updates, "outfit_name", request.OutfitName) + setIfNotEmpty(updates, "outfit_desc", request.OutfitDesc) + setIfNotEmpty(updates, "scene", request.Scene) + if len(updates) > 0 { + if err := s.outfitDao.OutfitUpdateByUUID(ctx, request.OutfitUUID, updates); err != nil { + return nil, errcode.OutfitUpdateFailed + } + } + + // 重建明细(事务内先删后建,避免中途失败留下空明细)。 + rebuildErr := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("outfit_id = ?", outfit.ID).Delete(&mysqlmodel.OutfitItem{}).Error; err != nil { + return err + } + items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs)) + for _, cid := range clothesIDs { + items = append(items, mysqlmodel.OutfitItem{OutfitID: outfit.ID, ClothesID: cid}) + } + if len(items) == 0 { + return nil + } + return tx.Create(&items).Error + }) + if rebuildErr != nil { + return nil, errcode.OutfitUpdateFailed + } + + return &dto.OutfitUpdateByUUIDResponse{}, nil +} + +// List 分页查询我的穿搭。 +func (s *OutfitService) List(ctx context.Context, userID int64, request *dto.OutfitListRequest) (*dto.OutfitListResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.OutfitList") + defer span.End() + + page, pageSize := normalizePage(request.Page, request.PageSize) + + list, total, err := s.outfitDao.OutfitListByUserID(ctx, userID, request.IsPublic, request.Source, page, pageSize) + if err != nil { + return nil, errcode.DatabaseError + } + + items, berr := s.buildOutfitListResponses(ctx, list) + if berr != nil { + return nil, errcode.DatabaseError + } + + return &dto.OutfitListResponse{ + PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, + List: items, + }, nil +} + +// Delete 删除穿搭(软删除穿搭 + 清理明细)。 +func (s *OutfitService) Delete(ctx context.Context, userID int64, request *dto.OutfitDeleteRequest) (*dto.OutfitDeleteResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.OutfitDelete") + defer span.End() + + outfit, cerr := s.getOwnedOutfit(ctx, request.OutfitUUID, userID) + if cerr != nil { + return nil, cerr + } + if err := s.outfitDao.OutfitDeleteByUUID(ctx, request.OutfitUUID); err != nil { + return nil, errcode.OutfitDeleteFailed + } + _ = s.outfitItemDao.OutfitItemDeleteByOutfitID(ctx, outfit.ID) + return &dto.OutfitDeleteResponse{}, nil +} + +// Public 设置穿搭公开状态。公开时自动审核通过并写审核日志、消耗社区分享额度。 +func (s *OutfitService) Public(ctx context.Context, userID int64, request *dto.OutfitPublicRequest) (*dto.OutfitPublicResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.OutfitPublic") + defer span.End() + + outfit, cerr := s.getOwnedOutfit(ctx, request.OutfitUUID, userID) + if cerr != nil { + return nil, cerr + } + + // 公开/取消公开流程加锁串行化:消除并发下「A 扣额失败回退公开态、B 幂等返回成功但最终未公开」 + // 的语义冲突;锁失败返回请求过于频繁,Redis 异常时降级放行(与项目 fail-open 策略一致)。 + lockKey := fmtLockKey(redismodel.KeyLockOutfitPublic, request.OutfitUUID) + lockVal := genUUID() + if ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 10*time.Second); lerr != nil { + slog.WarnContext(ctx, "acquire outfit public lock failed, proceed without lock", + "outfit_uuid", request.OutfitUUID, "error", lerr) + } else if !ok { + return nil, errcode.TooManyRequests + } else { + defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }() + } + + // 取消公开:直接置为不公开。 + if request.IsPublic == OutfitPrivate { + if outfit.IsPublic == OutfitPrivate { + return &dto.OutfitPublicResponse{}, nil + } + if err := s.outfitDao.OutfitUpdateIsPublic(ctx, request.OutfitUUID, OutfitPrivate); err != nil { + return nil, errcode.OutfitUpdateFailed + } + return &dto.OutfitPublicResponse{}, nil + } + + // 已公开则无需重复处理。 + if outfit.IsPublic == OutfitPublic { + return &dto.OutfitPublicResponse{}, nil + } + + // 首次公开:先条件更新(私密 -> 公开+审核通过),成功才消耗额度,防并发重复扣减。 + ok, perr := s.outfitDao.OutfitPublish(ctx, request.OutfitUUID) + if perr != nil { + return nil, errcode.OutfitUpdateFailed + } + if !ok { + // 已被并发公开,幂等返回。 + return &dto.OutfitPublicResponse{}, nil + } + + if qerr := consumeDailyQuota(ctx, userID, QuotaTypeOutfitShare, 1, "穿搭社区分享消耗"); qerr != nil { + // 扣额度失败,回退公开状态。 + _ = s.outfitDao.OutfitUpdateIsPublic(ctx, request.OutfitUUID, OutfitPrivate) + return nil, qerr + } + + // 写审核日志(自动通过,best-effort:失败仅告警不阻断公开)。 + if err := s.auditLogDao.OutfitAuditLogCreate(ctx, &mysqlmodel.OutfitAuditLog{ + TargetID: outfit.ID, + AuditResult: AuditResultPass, + AuditTime: nowUnix(), + }); err != nil { + slog.WarnContext(ctx, "write outfit audit log failed", "outfit_id", outfit.ID, "error", err) + } + + // 记录当日社区分享计数(统计)。 + recordDailyUsage(ctx, userID, QuotaTypeOutfitShare) + + return &dto.OutfitPublicResponse{}, nil +} + +// ---- 内部辅助 ---- + +// getOwnedOutfit 查询穿搭并校验归属。 +func (s *OutfitService) getOwnedOutfit(ctx context.Context, outfitUUID string, userID int64) (*mysqlmodel.Outfit, *errcode.Error) { + outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID) + if err != nil { + if isNotFound(err) { + return nil, errcode.OutfitNotFound + } + return nil, errcode.DatabaseError + } + if outfit.UserID != userID { + return nil, errcode.OutfitNoPermission + } + return outfit, nil +} + +// isOutfitPublicVisible 判断穿搭是否对他人公开可见(公开 + 审核通过 + 正常)。 +func isOutfitPublicVisible(o *mysqlmodel.Outfit) bool { + return o.IsPublic == OutfitPublic && o.AuditStatus == OutfitAuditPass && o.Status == OutfitStatusNormal +} + +// resolveOwnedClothes 校验请求中的衣物均属于该用户,返回有序去重的 clothesID 与 uuid 列表。 +func (s *OutfitService) resolveOwnedClothes(ctx context.Context, userID int64, items []dto.OutfitItemRequest) ([]int64, []dto.OutfitItemResponse, *errcode.Error) { + uuids := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, it := range items { + if _, ok := seen[it.ClothesUUID]; ok { + continue + } + seen[it.ClothesUUID] = struct{}{} + uuids = append(uuids, it.ClothesUUID) + } + + clothesList, err := s.clothesDao.ClothesGetByUUIDs(ctx, uuids) + if err != nil { + return nil, nil, errcode.DatabaseError + } + byUUID := make(map[string]mysqlmodel.Clothes, len(clothesList)) + for _, c := range clothesList { + byUUID[c.ClothesUUID] = c + } + + ids := make([]int64, 0, len(uuids)) + respItems := make([]dto.OutfitItemResponse, 0, len(uuids)) + for _, u := range uuids { + c, ok := byUUID[u] + if !ok { + return nil, nil, errcode.ClothesNotFound + } + if c.UserID != userID { + return nil, nil, errcode.ClothesNoPermission + } + ids = append(ids, c.ID) + respItems = append(respItems, buildOutfitItemResponse(&c)) + } + return ids, respItems, nil +} + +// createItems 批量创建穿搭明细。 +func (s *OutfitService) createItems(ctx context.Context, outfitID int64, clothesIDs []int64) error { + items := make([]mysqlmodel.OutfitItem, 0, len(clothesIDs)) + for _, cid := range clothesIDs { + items = append(items, mysqlmodel.OutfitItem{OutfitID: outfitID, ClothesID: cid}) + } + return s.outfitItemDao.OutfitItemCreateBatch(ctx, items) +} + +// itemResponses 获取某穿搭的衣物明细(含图片 URL)。 +func (s *OutfitService) itemResponses(ctx context.Context, outfitID int64) ([]dto.OutfitItemResponse, error) { + items, err := s.outfitItemDao.OutfitItemListByOutfitID(ctx, outfitID) + if err != nil { + return nil, err + } + ids := make([]int64, 0, len(items)) + for _, it := range items { + ids = append(ids, it.ClothesID) + } + clothesList, err := s.clothesDao.ClothesGetByIDs(ctx, ids) + if err != nil { + return nil, err + } + clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesList)) + for _, c := range clothesList { + clothesByID[c.ID] = c + } + resps := make([]dto.OutfitItemResponse, 0, len(items)) + for _, it := range items { + if c, ok := clothesByID[it.ClothesID]; ok { + resps = append(resps, buildOutfitItemResponse(&c)) + } + } + return resps, nil +} + +// buildOutfitListResponses 批量组装穿搭列表(一次性加载全部明细与衣物,避免 N+1)。 +func (s *OutfitService) buildOutfitListResponses(ctx context.Context, list []mysqlmodel.Outfit) ([]dto.OutfitCreateResponse, error) { + if len(list) == 0 { + return []dto.OutfitCreateResponse{}, nil + } + + outfitIDs := make([]int64, 0, len(list)) + for _, o := range list { + outfitIDs = append(outfitIDs, o.ID) + } + + allItems, err := s.outfitItemDao.OutfitItemListByOutfitIDs(ctx, outfitIDs) + if err != nil { + return nil, err + } + + clothesIDSet := make(map[int64]struct{}, len(allItems)) + itemsByOutfit := make(map[int64][]int64, len(list)) + for _, it := range allItems { + itemsByOutfit[it.OutfitID] = append(itemsByOutfit[it.OutfitID], it.ClothesID) + clothesIDSet[it.ClothesID] = struct{}{} + } + + clothesIDs := make([]int64, 0, len(clothesIDSet)) + for id := range clothesIDSet { + clothesIDs = append(clothesIDs, id) + } + clothesList, err := s.clothesDao.ClothesGetByIDs(ctx, clothesIDs) + if err != nil { + return nil, err + } + clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothesList)) + for _, c := range clothesList { + clothesByID[c.ID] = c + } + + resps := make([]dto.OutfitCreateResponse, 0, len(list)) + for i := range list { + o := &list[i] + items := make([]dto.OutfitItemResponse, 0, len(itemsByOutfit[o.ID])) + for _, cid := range itemsByOutfit[o.ID] { + if c, ok := clothesByID[cid]; ok { + items = append(items, buildOutfitItemResponse(&c)) + } + } + resps = append(resps, buildOutfitCreateResponse(o, items)) + } + return resps, nil +} + +// buildOutfitItemResponse 组装穿搭明细(processed_image 转为可访问 URL)。 +func buildOutfitItemResponse(c *mysqlmodel.Clothes) dto.OutfitItemResponse { + img := c.ProcessedImage + if img != "" { + img = upload.GetCosUrl(img) + } + return dto.OutfitItemResponse{ClothesUUID: c.ClothesUUID, ProcessedImage: img} +} + +// buildOutfitCreateResponse 组装穿搭响应。 +func buildOutfitCreateResponse(o *mysqlmodel.Outfit, items []dto.OutfitItemResponse) dto.OutfitCreateResponse { + return dto.OutfitCreateResponse{ + OutfitUUID: o.OutfitUUID, + OutfitName: o.OutfitName, + OutfitDesc: o.OutfitDesc, + Scene: o.Scene, + Source: o.Source, + IsPublic: o.IsPublic, + AuditStatus: o.AuditStatus, + Items: items, + } +} diff --git a/internal/service/quota.go b/internal/service/quota.go new file mode 100644 index 0000000..29a6f63 --- /dev/null +++ b/internal/service/quota.go @@ -0,0 +1,137 @@ +package service + +import ( + "context" + "log/slog" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" +) + +// QuotaService 额度业务服务。 +type QuotaService struct { + quotaDao *mysqldao.QuotaDao + flowLogDao *mysqldao.QuotaFlowLogDao +} + +// NewQuotaService 创建额度服务。 +func NewQuotaService() *QuotaService { + return &QuotaService{ + quotaDao: mysqldao.NewQuotaDao(), + flowLogDao: mysqldao.NewQuotaFlowLogDao(), + } +} + +// InfoGetBySelf 获取当前用户各类额度概览。 +// Cos 返回永久总额度/累计已用/剩余;AI/Outfit/Closet 返回每日总额度/当日已用/当日剩余。 +func (s *QuotaService) InfoGetBySelf(ctx context.Context, userID int64) (*dto.QuotaInfoGetBySelfResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.QuotaInfoGetBySelf") + defer span.End() + + types := []int8{QuotaTypeCos, QuotaTypeAI, QuotaTypeOutfitShare, QuotaTypeClosetShare} + items := make([]dto.QuotaItemResponse, 0, len(types)) + for _, quotaType := range types { + eff, err := getEffectiveQuota(ctx, userID, quotaType) + if err != nil { + return nil, err + } + items = append(items, dto.QuotaItemResponse{ + QuotaType: quotaType, + Total: eff.Total, + Used: eff.Used, + Remain: eff.Remain, + }) + } + + return &dto.QuotaInfoGetBySelfResponse{Quotas: items}, nil +} + +// FlowList 分页查询当前用户的额度流水。 +func (s *QuotaService) FlowList(ctx context.Context, userID int64, request *dto.QuotaFlowListRequest) (*dto.QuotaFlowListResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.QuotaFlowList") + defer span.End() + + page, pageSize := normalizePage(request.Page, request.PageSize) + + list, total, err := s.flowLogDao.QuotaFlowLogListByUserID(ctx, userID, request.QuotaType, page, pageSize) + if err != nil { + return nil, errcode.DatabaseError + } + + items := make([]dto.QuotaFlowItemResponse, 0, len(list)) + for _, f := range list { + items = append(items, dto.QuotaFlowItemResponse{ + QuotaFlowUUID: f.QuotaFlowUUID, + QuotaType: f.QuotaType, + ChangeTarget: f.ChangeTarget, + ChangeType: f.ChangeType, + ChangeNum: f.ChangeNum, + NewValue: f.NewValue, + Reason: f.Reason, + OrderNo: f.OrderNo, + CreatedAt: f.CreatedAt, + }) + } + + return &dto.QuotaFlowListResponse{ + PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize}, + List: items, + }, nil +} + +// ensureDefaultQuota 为新用户初始化 Cos 永久免费额度底值。 +// AI/Outfit/Closet 为每日额度,不落库,由 getEffectiveQuota 动态计算,无需初始化。 +// 事务内「创建 + 写流水」强一致,依赖 uk_user_quota 唯一索引实现并发安全幂等; +// best-effort:失败仅告警,不阻断登录主流程。 +func ensureDefaultQuota(ctx context.Context, userID int64) { + quotaDao := mysqldao.NewQuotaDao() + total := defaultQuotaValue(QuotaTypeCos) + + flow := &mysqlmodel.QuotaFlowLog{ + QuotaFlowUUID: genUUID(), + UserID: userID, + ChangeTarget: QuotaChangeTargetAdjust, + ChangeType: QuotaChangeTypeActivity, + ChangeNum: total, + QuotaType: QuotaTypeCos, + Reason: "新用户默认上传额度", + } + if err := quotaDao.QuotaInitWithFlow(ctx, userID, QuotaTypeCos, total, flow); err != nil { + slog.WarnContext(ctx, "init default cos quota failed", "user_id", userID, "error", err) + } +} + +// defaultQuotaValue 返回某类额度的新用户默认值: +// 优先取 global.UserDefaultQuotaSetting 的配置值,配置为 0 时同样生效(表示不赠送该类免费额度); +// 未配置(整段缺失或该项缺失,指针为 nil)或配置为负数时,回落内置常量。 +func defaultQuotaValue(quotaType int8) int64 { + cfg := global.UserDefaultQuotaSetting + + switch quotaType { + case QuotaTypeCos: + if cfg != nil && cfg.Cos != nil && *cfg.Cos >= 0 { + return *cfg.Cos + } + return DefaultQuotaCos + case QuotaTypeAI: + if cfg != nil && cfg.AI != nil && *cfg.AI >= 0 { + return *cfg.AI + } + return DefaultQuotaAI + case QuotaTypeOutfitShare: + if cfg != nil && cfg.OutfitShare != nil && *cfg.OutfitShare >= 0 { + return *cfg.OutfitShare + } + return DefaultQuotaOutfitShare + case QuotaTypeClosetShare: + if cfg != nil && cfg.ClosetShare != nil && *cfg.ClosetShare >= 0 { + return *cfg.ClosetShare + } + return DefaultQuotaClosetShare + default: + return 0 + } +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..3dd4ae3 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,465 @@ +// Package service 承载业务逻辑层,编排 dto、mysqldao、redisdao 与 pkg 能力。 +// +// 约定: +// - 每个业务域一个 Service 结构体,NewXxxService() 构造,内部持有所需 DAO。 +// - 方法统一返回 (*dto.XxxResponse, *errcode.Error),将底层 error 映射为语义化错误。 +// - redis 未命中返回 redis.Nil,按缓存未命中处理,不视为错误。 +// - 所有"我的"资源操作在本层做归属校验(AuthZ)。 +package service + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "github.com/go-sql-driver/mysql" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// isNotFound 判断 gorm 是否为「记录不存在」。 +func isNotFound(err error) bool { + return errors.Is(err, gorm.ErrRecordNotFound) +} + +// isCacheMiss 判断 redis 是否为「未命中」。 +func isCacheMiss(err error) bool { + return errors.Is(err, redis.Nil) +} + +// isDuplicateKey 判断是否为 MySQL 唯一键冲突(Error 1062),用于幂等发放/回退。 +func isDuplicateKey(err error) bool { + var mysqlErr *mysql.MySQLError + return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 +} + +// 分页默认值。 +const ( + defaultPage = 1 + defaultPageSize = 10 + maxPageSize = 50 +) + +// normalizePage 规整分页参数,返回可直接用于 DAO 的 page/pageSize。 +func normalizePage(page, pageSize int) (int, int) { + if page <= 0 { + page = defaultPage + } + if pageSize <= 0 { + pageSize = defaultPageSize + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + return page, pageSize +} + +// nowUnix 当前秒级时间戳。 +func nowUnix() int64 { + return time.Now().Unix() +} + +// todayStatDate 返回本地时区今日的 YYYYMMDD 整数。 +func todayStatDate() int64 { + now := time.Now() + return int64(now.Year()*10000 + int(now.Month())*100 + now.Day()) +} + +// genUUID 生成新的 UUID 字符串。 +func genUUID() string { + return uuid.NewString() +} + +// recordDailyUsage 记录用户当日使用计数(best-effort,用于统计,不作为硬限制;失败仅告警)。 +// kind 复用 QuotaType 语义:QuotaTypeAI / QuotaTypeOutfitShare / QuotaTypeClosetShare。 +func recordDailyUsage(ctx context.Context, userID int64, kind int8) { + usageDao := mysqldao.NewUserDailyUsageDao() + statDate := todayStatDate() + + // 确保当日计数行存在。 + if _, err := usageDao.UserDailyUsageGetByUserIDAndDate(ctx, userID, statDate); err != nil { + if isNotFound(err) { + if cerr := usageDao.UserDailyUsageCreate(ctx, &mysqlmodel.UserDailyUsage{ + UserID: userID, + StatDate: statDate, + }); cerr != nil { + slog.WarnContext(ctx, "create daily usage failed", "user_id", userID, "error", cerr) + return + } + } else { + slog.WarnContext(ctx, "get daily usage failed", "user_id", userID, "error", err) + return + } + } + + var err error + switch kind { + case QuotaTypeAI: + err = usageDao.UserDailyUsageIncrementAI(ctx, userID, statDate) + case QuotaTypeOutfitShare: + err = usageDao.UserDailyUsageIncrementOutfitShare(ctx, userID, statDate) + case QuotaTypeClosetShare: + err = usageDao.UserDailyUsageIncrementClosetShare(ctx, userID, statDate) + } + if err != nil { + slog.WarnContext(ctx, "increment daily usage failed", "user_id", userID, "kind", kind, "error", err) + } +} + +// ---- 新额度体系:每日额度 + 凭证有效期 ---- + +// EffectiveQuota 某类额度的有效额度概览。 +// Cos:Total=永久总额度、Used=永久累计已用、Remain=永久剩余; +// AI/Outfit/Closet:Total=每日总额度、Used=当日已用、Remain=当日剩余。 +type EffectiveQuota struct { + Total int64 + Used int64 + Remain int64 +} + +// getUserVIPExpire 读取用户 VIP 到期时间(秒);用户不存在或查询失败返回 0(视为非会员)。 +func getUserVIPExpire(ctx context.Context, userID int64) int64 { + user, err := mysqldao.NewUserDao().UserGetByID(ctx, userID) + if err != nil { + return 0 + } + return user.VIPExpire +} + +// grantExpired 判断凭证是否已失效:expire_time=0 表示跟随 VIP,其余按到期时间判断。 +func grantExpired(expireTime, vipExpire, now int64) bool { + if expireTime == 0 { + return vipExpire <= now + } + return expireTime <= now +} + +// listValidGrants 查询用户某类型当前有效的额度凭证。 +func listValidGrants(ctx context.Context, userID int64, quotaType int8, vipExpire, now int64) ([]mysqlmodel.QuotaGrant, error) { + grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByUserIDAndType(ctx, userID, quotaType) + if err != nil { + return nil, err + } + valid := make([]mysqlmodel.QuotaGrant, 0, len(grants)) + for _, g := range grants { + if !grantExpired(g.ExpireTime, vipExpire, now) { + valid = append(valid, g) + } + } + return valid, nil +} + +// getEffectiveQuota 返回某类额度的有效额度概览(Cos 永久 / 每日额度)。 +// 有效总额度 = 免费配置值 + SUM(有效凭证 amount);已用按类型分别取永久累计或当日计数。 +func getEffectiveQuota(ctx context.Context, userID int64, quotaType int8) (*EffectiveQuota, *errcode.Error) { + now := nowUnix() + freeTotal := defaultQuotaValue(quotaType) + + vipExpire := getUserVIPExpire(ctx, userID) + grants, err := listValidGrants(ctx, userID, quotaType, vipExpire, now) + if err != nil { + return nil, errcode.DatabaseError + } + + var grantTotal, grantUsed int64 + for i := range grants { + grantTotal += grants[i].Amount + grantUsed += grants[i].Used + } + + if quotaType == QuotaTypeCos { + // 永久额度:免费已用来自 user_quotas.used(永久累计),赠送已用来自凭证 used。 + freeUsed := int64(0) + if q, qerr := mysqldao.NewQuotaDao().QuotaGetByUserIDAndType(ctx, userID, quotaType); qerr == nil { + freeUsed = q.Used + } + total := freeTotal + grantTotal + used := freeUsed + grantUsed + remain := total - used + if remain < 0 { + remain = 0 + } + return &EffectiveQuota{Total: total, Used: used, Remain: remain}, nil + } + + // 每日额度:免费与赠送合并为每日总额度,已用为当日计数。 + total := freeTotal + grantTotal + used := getDailyUsed(ctx, userID, quotaType) + remain := total - used + if remain < 0 { + remain = 0 + } + return &EffectiveQuota{Total: total, Used: used, Remain: remain}, nil +} + +// dailyQuotaKey 按额度类型构造当日计数 Redis key。 +func dailyQuotaKey(quotaType int8, userID int64, date int64) string { + switch quotaType { + case QuotaTypeAI: + return fmt.Sprintf(redismodel.KeyQuotaDailyAI, userID, date) + case QuotaTypeOutfitShare: + return fmt.Sprintf(redismodel.KeyQuotaDailyOutfitShare, userID, date) + case QuotaTypeClosetShare: + return fmt.Sprintf(redismodel.KeyQuotaDailyClosetShare, userID, date) + default: + return "" + } +} + +// getDailyUsed 读取当日已用计数(Redis 优先,异常回落 MySQL 统计)。 +func getDailyUsed(ctx context.Context, userID int64, quotaType int8) int64 { + key := dailyQuotaKey(quotaType, userID, todayStatDate()) + if key == "" { + return 0 + } + if val, err := redisdao.DailyQuotaValue(ctx, key); err == nil { + return val + } + return getDailyUsedFromMySQL(ctx, userID, quotaType) +} + +// getDailyUsedFromMySQL 从 user_daily_usages 读取当日计数(Redis 故障兜底)。 +func getDailyUsedFromMySQL(ctx context.Context, userID int64, quotaType int8) int64 { + usage, err := mysqldao.NewUserDailyUsageDao().UserDailyUsageGetByUserIDAndDate(ctx, userID, todayStatDate()) + if err != nil { + return 0 + } + switch quotaType { + case QuotaTypeAI: + return usage.AIRecommendCount + case QuotaTypeOutfitShare: + return usage.OutfitShareCount + case QuotaTypeClosetShare: + return usage.ClosetShareCount + default: + return 0 + } +} + +// consumeDailyQuota 原子消费 AI/Outfit/Closet 当日额度。 +// 额度不足返回 errcode.QuotaInsufficient;Redis 异常返回 errcode.CacheError。 +func consumeDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64, reason string) *errcode.Error { + quota, qerr := getEffectiveQuota(ctx, userID, quotaType) + if qerr != nil { + return qerr + } + key := dailyQuotaKey(quotaType, userID, todayStatDate()) + if key == "" { + return errcode.QuotaInsufficient + } + val, err := redisdao.DailyQuotaConsume(ctx, key, quota.Total, n) + if err != nil { + return errcode.CacheError + } + if val < 0 { + return errcode.QuotaInsufficient + } + // 同步 MySQL 当日计数(统计/兜底)。 + recordDailyUsage(ctx, userID, quotaType) + return nil +} + +// refundDailyQuota 业务失败时回退当日已用计数(best-effort,保证不小于 0)。 +func refundDailyQuota(ctx context.Context, userID int64, quotaType int8, n int64) { + key := dailyQuotaKey(quotaType, userID, todayStatDate()) + if key == "" { + return + } + if err := redisdao.DailyQuotaRefund(ctx, key, n); err != nil { + slog.WarnContext(ctx, "refund daily quota failed", "user_id", userID, "quota_type", quotaType, "error", err) + } +} + +// consumeCosQuota 消费 Cos 永久额度(先到期凭证先用,再扣免费额度),事务内保证数值与流水一致。 +func consumeCosQuota(ctx context.Context, userID int64, n int64, relatedID int64, reason string) *errcode.Error { + now := nowUnix() + vipExpire := getUserVIPExpire(ctx, userID) + + err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 1. 有效凭证(expire_time>now,或跟随 VIP 且 VIP 有效),按先到期先用锁定。 + var grants []mysqlmodel.QuotaGrant + q := tx.Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos) + if vipExpire > now { + q = q.Where("(expire_time > ? OR expire_time = 0)", now) + } else { + q = q.Where("expire_time > ?", now) + } + if err := q.Order("CASE WHEN expire_time = 0 THEN 1 ELSE 0 END ASC"). + Order("expire_time ASC"). + Clauses(clause.Locking{Strength: "UPDATE"}). + Find(&grants).Error; err != nil { + return err + } + + remain := n + for i := range grants { + if remain <= 0 { + break + } + avail := grants[i].Amount - grants[i].Used + if avail <= 0 { + continue + } + delta := avail + if delta > remain { + delta = remain + } + if err := tx.Model(&mysqlmodel.QuotaGrant{}). + Where("id = ? AND used + ? <= amount", grants[i].ID, delta). + UpdateColumn("used", gorm.Expr("used + ?", delta)).Error; err != nil { + return err + } + remain -= delta + } + + // 2. 凭证不够再扣免费额度(免费上限以配置值为准,避免历史 user_quotas.total 污染)。 + if remain > 0 { + freeLimit := defaultQuotaValue(QuotaTypeCos) + res := tx.Model(&mysqlmodel.Quota{}). + Where("user_id = ? AND quota_type = ? AND used + ? <= ?", userID, QuotaTypeCos, remain, freeLimit). + UpdateColumn("used", gorm.Expr("used + ?", remain)) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errcode.QuotaInsufficient + } + } + + // 3. 写消费流水(old/new 记录 Cos 有效剩余)。 + before := cosRemainTx(tx, userID, vipExpire, now) + flow := &mysqlmodel.QuotaFlowLog{ + QuotaFlowUUID: genUUID(), + UserID: userID, + ChangeTarget: QuotaChangeTargetUse, + ChangeType: QuotaChangeTypeConsume, + ChangeNum: -n, + OldValue: before + n, + NewValue: before, + QuotaType: QuotaTypeCos, + RelatedID: relatedID, + Reason: reason, + } + // 消费类流水无业务订单号,用流水 UUID 填充 order_no 避免唯一索引空串冲突。 + flow.OrderNo = flow.QuotaFlowUUID + return tx.Create(flow).Error + }) + + if err != nil { + if errors.Is(err, errcode.QuotaInsufficient) { + return errcode.QuotaInsufficient + } + return errcode.DatabaseError + } + return nil +} + +// cosRemainTx 在事务内计算 Cos 有效剩余(免费剩余 + 有效凭证剩余)。 +func cosRemainTx(tx *gorm.DB, userID int64, vipExpire, now int64) int64 { + freeRemain := int64(0) + var freeQ mysqlmodel.Quota + if err := tx.Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos).First(&freeQ).Error; err == nil { + freeRemain = defaultQuotaValue(QuotaTypeCos) - freeQ.Used + } + + var grantRemain int64 + q := tx.Model(&mysqlmodel.QuotaGrant{}).Where("user_id = ? AND quota_type = ?", userID, QuotaTypeCos) + if vipExpire > now { + q = q.Where("(expire_time > ? OR expire_time = 0)", now) + } else { + q = q.Where("expire_time > ?", now) + } + if err := q.Select("COALESCE(SUM(amount - used), 0)").Scan(&grantRemain).Error; err != nil { + slog.WarnContext(context.Background(), "calc cos grant remain failed", "user_id", userID, "error", err) + } + if freeRemain < 0 { + freeRemain = 0 + } + return freeRemain + grantRemain +} + +// refundCosQuotaTx 在事务内回退 Cos 额度并写流水。 +// 回退策略(后进先出):优先回退免费额度(最后扣减的),不足再回退凭证(最后到期优先)。 +func refundCosQuotaTx(tx *gorm.DB, userID int64, vipExpire int64, n int64, relatedID int64, reason string) error { + now := nowUnix() + + remain := n + // 1. 优先回退免费额度(used >= n 时整体回退,否则不满足则跳过)。 + res := tx.Model(&mysqlmodel.Quota{}). + Where("user_id = ? AND quota_type = ? AND used >= ?", userID, QuotaTypeCos, remain). + UpdateColumn("used", gorm.Expr("used - ?", remain)) + if res.Error != nil { + return res.Error + } + if res.RowsAffected > 0 { + remain = 0 + } + + // 2. 免费额度不足,回退凭证(按到期时间倒序,最后到期的优先回退)。 + if remain > 0 { + var grants []mysqlmodel.QuotaGrant + if err := tx.Where("user_id = ? AND quota_type = ? AND used > 0", userID, QuotaTypeCos). + Order("expire_time DESC"). + Clauses(clause.Locking{Strength: "UPDATE"}). + Find(&grants).Error; err != nil { + return err + } + for i := range grants { + if remain <= 0 { + break + } + delta := grants[i].Used + if delta > remain { + delta = remain + } + if err := tx.Model(&mysqlmodel.QuotaGrant{}). + Where("id = ? AND used >= ?", grants[i].ID, delta). + UpdateColumn("used", gorm.Expr("used - ?", delta)).Error; err != nil { + return err + } + remain -= delta + } + if remain > 0 { + // 无足够已用额度可回退(数据异常),跳过写流水。 + return nil + } + } + + // 3. 写回退流水。 + after := cosRemainTx(tx, userID, vipExpire, now) + flow := &mysqlmodel.QuotaFlowLog{ + QuotaFlowUUID: genUUID(), + UserID: userID, + ChangeTarget: QuotaChangeTargetUse, + ChangeType: QuotaChangeTypeRefund, + ChangeNum: n, + OldValue: after - n, + NewValue: after, + QuotaType: QuotaTypeCos, + RelatedID: relatedID, + Reason: reason, + } + // 回退类流水无业务订单号,用流水 UUID 填充 order_no 避免唯一索引空串冲突。 + flow.OrderNo = flow.QuotaFlowUUID + return tx.Create(flow).Error +} + +// refundCosQuota 独立事务回退 Cos 额度(上传失败等非事务场景)。 +func refundCosQuota(ctx context.Context, userID int64, n int64, relatedID int64, reason string) { + vipExpire := getUserVIPExpire(ctx, userID) + if err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return refundCosQuotaTx(tx, userID, vipExpire, n, relatedID, reason) + }); err != nil { + slog.WarnContext(ctx, "refund cos quota failed", "user_id", userID, "error", err) + } +} diff --git a/internal/service/user.go b/internal/service/user.go new file mode 100644 index 0000000..6e6853d --- /dev/null +++ b/internal/service/user.go @@ -0,0 +1,417 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "mime/multipart" + "strings" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/lzcwyjwt" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat" +) + +// UserService 用户业务服务。 +type UserService struct { + userDao *mysqldao.UserDao +} + +// NewUserService 创建用户服务。 +func NewUserService() *UserService { + return &UserService{ + userDao: mysqldao.NewUserDao(), + } +} + +// WxLogin 微信小程序登录:code2session 换取 openid,首次登录自动注册并初始化默认额度。 +func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginRequest) (*dto.UserWxLoginResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.UserWxLogin") + defer span.End() + + sess, err := wechat.Code2Session(ctx, request.Code) + if err != nil { + var codeErr *wechat.Code2SessionError + if errors.As(err, &codeErr) && codeErr.IsInvalidCode() { + return nil, errcode.UserWxCodeInvalid + } + return nil, errcode.UserWxLoginFailed + } + + isNew := false + // userCacheStale 标记内存中的 user 对象已落后于数据库(如赠送会员后回读用户失败)。 + // 为 true 时跳过用户信息缓存回写,避免用过期的非会员对象覆盖缓存(下次读取会走 DB 回填)。 + userCacheStale := false + user, err := s.userDao.UserGetByOpenID(ctx, sess.OpenID) + if err != nil { + if !isNotFound(err) { + return nil, errcode.DatabaseError + } + // 首次登录:注册新用户。 + user = &mysqlmodel.User{ + UserUUID: genUUID(), + WxOpenID: sess.OpenID, + Username: "微信用户", + IsNewUser: true, + Status: UserStatusNormal, + VIPType: VIPTypeNone, + LastLoginTime: nowUnix(), + } + if cerr := s.userDao.UserCreate(ctx, user); cerr != nil { + if isDuplicateKey(cerr) { + // 并发注册冲突:重查已存在的用户继续登录。 + user, err = s.userDao.UserGetByOpenID(ctx, sess.OpenID) + if err != nil { + return nil, errcode.DatabaseError + } + } else { + return nil, errcode.UserCreateFailed + } + } else { + isNew = true + // 初始化默认免费额度(best-effort,内部失败已告警,不阻断登录)。 + ensureDefaultQuota(ctx, user.ID) + // 赠送体验会员(best-effort);赠送成功后回读用户, + // 保证后续写入缓存与登录响应的是会员态,避免被内存中的非会员对象覆盖。 + if s.ensureTrialVIP(ctx, user.ID) > 0 { + if fresh, ferr := s.userDao.UserGetByID(ctx, user.ID); ferr == nil { + user = fresh + } else { + // 库中已是会员态但回读失败:不回写缓存,交由下次读取走 DB 回填。 + slog.WarnContext(ctx, "reload user after trial vip failed", "user_id", user.ID, "error", ferr) + userCacheStale = true + } + } + } + } + + // 非正常状态账号(临时/永久封禁、已注销)禁止登录。 + if user.Status != UserStatusNormal { + return nil, errcode.UserDisabled + } + + if !isNew { + _ = s.userDao.UserUpdateByID(ctx, user.ID, map[string]any{"last_login_time": nowUnix()}) + } + + accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(user.ID) + if terr != nil { + return nil, errcode.UnauthorizedTokenGenerate + } + if aerr := redisdao.AuthTokenSet(ctx, user.ID, accessToken, refreshToken); aerr != nil { + // Redis 故障降级放行(与鉴权中间件 fail-open 策略一致),避免 Redis 抖动导致全员无法登录。 + slog.WarnContext(ctx, "set auth token failed, allow by fallback", "error", aerr) + } + + // 缓存 session_key(虚拟支付 signature 计算依赖;best-effort,失败不阻断登录, + // 虚拟支付下单时若缺失会提示重新登录)。 + if sess.SessionKey != "" { + if serr := redisdao.AuthSessionKeySet(ctx, user.ID, sess.SessionKey); serr != nil { + slog.WarnContext(ctx, "set session key failed", "user_id", user.ID, "error", serr) + } + } + + // 刷新缓存中的用户信息(best-effort);对象已落后于数据库时不回写。 + if !userCacheStale { + _ = redisdao.UserInfoSet(ctx, user, 0) + } + + info := buildUserInfoResponse(user) + info.IsNewUser = isNew + + return &dto.UserWxLoginResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire), + UserInfo: info, + }, nil +} + +// InfoGetBySelf 获取当前用户信息,优先读缓存。 +func (s *UserService) InfoGetBySelf(ctx context.Context, userID int64) (*dto.UserInfoGetBySelfResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.UserInfoGetBySelf") + defer span.End() + + if user, err := redisdao.UserInfoGet(ctx, userID); err == nil && user != nil { + info := buildUserInfoResponse(user) + return &info, nil + } else if err != nil && !isCacheMiss(err) { + // 缓存异常不阻断,继续回落 DB。 + } + + user, err := s.userDao.UserGetByID(ctx, userID) + if err != nil { + if isNotFound(err) { + return nil, errcode.UserNotFound + } + return nil, errcode.DatabaseError + } + _ = redisdao.UserInfoSet(ctx, user, 0) + + info := buildUserInfoResponse(user) + return &info, nil +} + +// ProfileUpdate 修改当前用户资料。 +func (s *UserService) ProfileUpdate(ctx context.Context, userID int64, request *dto.UserProfileUpdateBySelfRequest) (*dto.UserProfileUpdateBySelfResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.UserProfileUpdate") + defer span.End() + + // 字段校验:昵称长度、头像长度、性别枚举、生日范围。 + if u := strings.TrimSpace(request.Username); u != "" { + if len([]rune(u)) > 32 { + return nil, errcode.InvalidParams.WithDetails("昵称长度不能超过 32 个字符") + } + request.Username = u + } + if request.Avatar != "" && len([]rune(request.Avatar)) > 500 { + return nil, errcode.InvalidParams.WithDetails("头像地址过长") + } + if request.Sex != 0 && request.Sex != SexMale && request.Sex != SexFemale { + return nil, errcode.InvalidParams.WithDetails("性别参数不合法") + } + if request.BirthDay != 0 && request.BirthDay > nowUnix() { + return nil, errcode.InvalidParams.WithDetails("生日不能晚于当前时间") + } + + updates := map[string]any{} + if request.Username != "" { + updates["username"] = request.Username + } + if request.Avatar != "" { + updates["avatar"] = request.Avatar + } + if request.Sex != 0 { + updates["sex"] = request.Sex + } + if request.BirthDay != 0 { + updates["birth_day"] = request.BirthDay + } + if len(updates) == 0 { + return &dto.UserProfileUpdateBySelfResponse{}, nil + } + + if err := s.userDao.UserUpdateByID(ctx, userID, updates); err != nil { + return nil, errcode.UserUpdateFailed + } + // 失效缓存,下次读取回填最新数据。 + _ = redisdao.UserInfoDel(ctx, userID) + + return &dto.UserProfileUpdateBySelfResponse{}, nil +} + +// AvatarUpload 上传用户头像:校验图片 → 落临时目录 → 上传 COS → 更新 users.avatar(存 COS key)→ 失效缓存, +// 返回可直接访问的头像 URL。头像属基础资料,不消耗衣物 COS 上传额度。 +func (s *UserService) AvatarUpload(ctx context.Context, userID int64, fileHeader *multipart.FileHeader) (*dto.UserAvatarUploadResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.UserAvatarUpload") + defer span.End() + + if fileHeader == nil || fileHeader.Size == 0 { + return nil, errcode.UploadFileEmpty + } + + // 1. 校验扩展名与大小。 + ext := upload.FileGetExt(fileHeader.Filename) + if !upload.FileCheckAllowExt(ext, global.UploadSetting.AllowedExtensions) { + return nil, errcode.UploadFileExtNotAllow + } + if !upload.FileCheckMaxSize(fileHeader.Size, int64(global.UploadSetting.MaxSizeMB)) { + return nil, errcode.UploadFileTooLarge + } + + // 2. 读取原图字节(必须同步读取,请求返回后 multipart 临时文件会被清理)。 + src, oerr := fileHeader.Open() + if oerr != nil { + return nil, errcode.UploadFileSaveFailed + } + imageData, rerr := io.ReadAll(src) + _ = src.Close() + if rerr != nil { + return nil, errcode.UploadFileSaveFailed + } + + // 3. 校验文件内容为真实图片(magic bytes),防止伪造扩展名上传恶意文件。 + if !upload.FileCheckImageMagic(imageData) { + return nil, errcode.UploadFileExtNotAllow + } + + // 4. 获取用户 UUID,构造 COS key:{userUUID}/{userID}/avatar/{随机名+扩展名}。 + user, uerr := s.userDao.UserGetByID(ctx, userID) + if uerr != nil { + if isNotFound(uerr) { + return nil, errcode.UserNotFound + } + return nil, errcode.DatabaseError + } + fileName, nerr := upload.FileGenName(fileHeader.Filename) + if nerr != nil { + return nil, errcode.UploadFileSaveFailed + } + subDir := fmt.Sprintf("%s/%d/avatar", user.UserUUID, userID) + if _, serr := upload.FileSaveTmpBytes(ctx, imageData, fileName, subDir); serr != nil { + return nil, errcode.UploadFileSaveFailed + } + cosKey := fmt.Sprintf("%s/%s", subDir, fileName) + if cerr := upload.FileUploadCos(ctx, cosKey); cerr != nil { + return nil, errcode.UploadFileSaveFailed + } + + // 5. 更新 avatar(存 COS key,读取时统一转 URL)。 + if uerr := s.userDao.UserUpdateByID(ctx, userID, map[string]any{"avatar": cosKey}); uerr != nil { + return nil, errcode.UserUpdateFailed + } + + // 6. 失效缓存,下次读取回填最新头像。 + _ = redisdao.UserInfoDel(ctx, userID) + + return &dto.UserAvatarUploadResponse{Avatar: upload.GetCosUrl(cosKey)}, nil +} + +// RefreshToken 使用 refresh token 刷新访问令牌。 +func (s *UserService) RefreshToken(ctx context.Context, request *dto.UserRefreshTokenRequest) (*dto.UserRefreshTokenResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.UserRefreshToken") + defer span.End() + + claims, err := lzcwyjwt.ParseRefreshToken(request.RefreshToken) + if err != nil { + if errors.Is(err, lzcwyjwt.ErrTokenExpired) { + return nil, errcode.UnauthorizedTokenTimeout + } + return nil, errcode.UnauthorizedTokenError + } + + // 校验 refresh token 仍在有效会话集合中。 + uid, aerr := redisdao.AuthUserIDByRefresh(ctx, request.RefreshToken) + if aerr != nil { + if isCacheMiss(aerr) { + return nil, errcode.UnauthorizedTokenError + } + return nil, errcode.CacheError + } + if uid != claims.UserID { + return nil, errcode.UnauthorizedTokenError + } + + accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(claims.UserID) + if terr != nil { + return nil, errcode.UnauthorizedTokenGenerate + } + + // 失效旧会话:强制携带旧 access token,精确吊销旧 access + 旧 refresh,防止旧 token 泄露后被继续使用。 + if request.AccessToken == "" { + return nil, errcode.InvalidParams.WithDetails("缺少旧 access token") + } + oldUID, oerr := redisdao.AuthUserIDByAccess(ctx, request.AccessToken) + if oerr != nil && !isCacheMiss(oerr) { + return nil, errcode.CacheError + } + if oerr == nil && oldUID != claims.UserID { + return nil, errcode.UnauthorizedTokenError + } + _ = redisdao.AuthTokenDel(ctx, claims.UserID, request.AccessToken, request.RefreshToken) + if serr := redisdao.AuthTokenSet(ctx, claims.UserID, accessToken, refreshToken); serr != nil { + return nil, errcode.CacheError + } + + return &dto.UserRefreshTokenResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire), + }, nil +} + +// defaultVipDays 返回新用户注册赠送的体验会员天数: +// 优先取 UserDefaultQuota.GiftVIPDay 的配置值,配置为 0 时生效(表示不赠送); +// 未配置(整段缺失或该项缺失,指针为 nil)或配置为负数时,回落内置常量 DefaultVipDays。 +func defaultVipDays() int64 { + cfg := global.UserDefaultQuotaSetting + if cfg == nil || cfg.GiftVIPDay == nil || *cfg.GiftVIPDay < 0 { + return DefaultVipDays + } + return *cfg.GiftVIPDay +} + +// ensureTrialVIP 新用户注册赠送体验会员(best-effort:任何失败仅告警,不阻断注册)。 +// +// 幂等:依赖 vip_flow_logs 的 uk_vip_flow_idem(order_no, change_type) 唯一索引; +// 注册场景没有订单号,使用 register_gift_ 作为幂等键 —— 必须携带 userID, +// 若写成固定常量则全站只能发放一次(第二个用户注册即冲突)。 +// +// 返回赠送后的会员到期时间(秒);未赠送或已发放过返回 0。 +func (s *UserService) ensureTrialVIP(ctx context.Context, userID int64) int64 { + days := defaultVipDays() + if days <= 0 { + return 0 + } + + ctx, span := global.Tracer.Start(ctx, "svc.EnsureTrialVIP") + defer span.End() + + flow := &mysqlmodel.VIPFlowLog{ + UserID: userID, + ChangeType: VIPChangeTypeActivity, + OldVIPType: VIPTypeNone, + NewVIPType: VIPTypeTrial, + OldVIPExpire: 0, + OrderNo: fmt.Sprintf("register_gift_%d", userID), + Source: VIPSourceSystem, + Reason: "新用户注册赠送体验会员", + } + + if err := s.userDao.VIPGrantIdempotent(ctx, userID, nowUnix(), days, VIPTypeTrial, flow); err != nil { + if isDuplicateKey(err) { + // 唯一索引冲突:该用户已赠送过,按已发放处理。 + return 0 + } + span.RecordError(err) + slog.WarnContext(ctx, "grant trial vip failed", "user_id", userID, "days", days, "error", err) + return 0 + } + + // 会员信息已变更,失效缓存(注册流程随后会用回读后的用户重新写入)。 + _ = redisdao.UserInfoDel(ctx, userID) + + sendNotification(ctx, userID, 0, NotificationTypeSystem, "欢迎礼包已到账", + fmt.Sprintf("已为你开通 %d 天体验会员,有效期至 %s", + days, time.Unix(flow.NewVIPExpire, 0).Format("2006-01-02"))) + + return flow.NewVIPExpire +} + +// buildUserInfoResponse 将 User 模型映射为用户信息响应。 +// 会员已过期(vip_expire <= now)时把 vip_type 归一为 1:项目没有会员到期归位任务, +// 过期会员的 vip_type 仍保留原值,归一可避免调用方仅凭 vip_type 误判为有效会员(只影响响应,不写库)。 +func buildUserInfoResponse(user *mysqlmodel.User) dto.UserInfoGetBySelfResponse { + vipType := user.VIPType + if vipType != VIPTypeNone && user.VIPExpire <= nowUnix() { + vipType = VIPTypeNone + } + + // 头像兼容转换:历史通过修改资料接口传入的 http(s) URL 直接透传; + // 头像上传接口写入的 COS key 统一转为可访问 URL。 + avatar := upload.AvatarURL(user.Avatar) + + return dto.UserInfoGetBySelfResponse{ + Username: user.Username, + Avatar: avatar, + Sex: user.Sex, + BirthDay: user.BirthDay, + Phone: user.Phone, + IsNewUser: user.IsNewUser, + Status: user.Status, + BanReason: user.BanReason, + BanStart: user.BanStart, + BanExpire: user.BanExpire, + VIPType: vipType, + VIPExpire: user.VIPExpire, + } +} diff --git a/internal/service/virtualpay.go b/internal/service/virtualpay.go new file mode 100644 index 0000000..db53220 --- /dev/null +++ b/internal/service/virtualpay.go @@ -0,0 +1,433 @@ +package service + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat" + "github.com/redis/go-redis/v9" +) + +// VirtualOrderCreate 创建虚拟支付订单:本地落单(pay_channel=2)并返回前端调起 +// wx.requestVirtualPayment 所需的 signData / paySig / signature。 +func (s *OrderService) VirtualOrderCreate(ctx context.Context, userID int64, request *dto.VirtualOrderCreateRequest) (*dto.VirtualOrderCreateResponse, *errcode.Error) { + ctx, span := global.Tracer.Start(ctx, "svc.VirtualOrderCreate") + defer span.End() + + if !wechat.IsVirtualPayEnabled() { + return nil, errcode.VirtualPayNotEnabled + } + // 订单记录当前激活的虚拟支付环境(现网 0 / 沙箱 1),发货推送按此校验。 + xpayEnv, verr := wechat.GetVirtualPayEnv() + if verr != nil { + return nil, errcode.VirtualPayNotEnabled + } + + goods, err := s.goodsDao.GoodsGetByCode(ctx, request.GoodsCode) + if err != nil { + if isNotFound(err) { + return nil, errcode.GoodsNotFound + } + return nil, errcode.DatabaseError + } + if goods.Status != GoodsStatusOnShelf { + return nil, errcode.GoodsOffShelf + } + if goods.ProductID == "" || goods.ProductStatus != GoodsProductPublished { + return nil, errcode.VirtualPayGoodsNotPublished + } + // iOS 端微信限制最低支付 1 元(100 分),低于此金额调起支付必失败,提前拒绝以降低垃圾订单。 + // 注意:platform 由前端上报(不可作为安全边界,可伪造为 android 绕过此限制); + // 此校验仅作体验优化,不构成资损防线——真正的资损防线是发货推送 verifyXPayOrderPaid + // 对微信侧订单真实性、金额、道具的反查。低金额商品在 iOS 上的垃圾订单靠过期清理兜底。 + if request.Platform == XPayPlatformIOS && goods.DiscountPrice < 100 { + return nil, errcode.VirtualPayAmountTooLow + } + + // 虚拟支付 signature 依赖用户 session_key(登录时缓存),缺失说明需重新登录。 + sessionKey, serr := redisdao.AuthSessionKeyGet(ctx, userID) + if serr != nil { + if serr == redis.Nil { + return nil, errcode.UnauthorizedTokenError.WithDetails("缺少 session_key,请重新登录") + } + return nil, errcode.CacheError + } + + orderNo := genOrderNo() + expireTime := nowUnix() + orderPayTTLSeconds + payAmount := goods.DiscountPrice + platform := request.Platform + + order := &mysqlmodel.Order{ + OrderNo: orderNo, + UserID: userID, + GoodsID: goods.ID, + GoodsName: goods.GoodsName, + UnitPrice: goods.DiscountPrice, + TotalAmount: payAmount, + PayAmount: payAmount, + PayChannel: PayChannelVirtual, + OrderStatus: OrderStatusPending, + MemberDays: goods.MemberDays, + CosQuota: goods.CosQuota, + AIRecommendQuota: goods.AIRecommendQuota, + ClosetShareQuota: goods.ClosetShareQuota, + OutfitShareQuota: goods.OutfitShareQuota, + ExpireTime: expireTime, + XPayProductID: goods.ProductID, + XPayPlatform: platform, + XPayEnv: int8(xpayEnv), + } + if cerr := s.orderDao.OrderCreate(ctx, order); cerr != nil { + return nil, errcode.VirtualOrderCreateFailed + } + + sign, perr := wechat.BuildPaymentSign( + global.VirtualPaySetting.OfferID, + goods.ProductID, + orderNo, + "", + sessionKey, + payAmount, + ) + if perr != nil { + return nil, errcode.VirtualOrderCreateFailed + } + + return &dto.VirtualOrderCreateResponse{ + OrderNo: orderNo, + PayAmount: payAmount, + ExpireTime: expireTime, + SignData: sign.SignData, + PaySig: sign.PaySig, + Signature: sign.Signature, + }, nil +} + +// ---- 消息推送回调 ---- + +// XPayNotify 处理虚拟支付消息推送(发货/退款/iOS 退款问询/其它),返回应答报文。 +func (s *OrderService) XPayNotify(ctx context.Context, raw []byte) []byte { + ctx, span := global.Tracer.Start(ctx, "svc.XPayNotify") + defer span.End() + + base, err := wechat.ParseNotify(raw) + if err != nil { + slog.ErrorContext(ctx, "xpay notify parse failed", "error", err) + return wechat.BuildNotifyResponse(false) + } + + switch base.Event { + case wechat.EventGoodsDeliverNotify: + var deliver wechat.XPayGoodsDeliverNotify + if err := json.Unmarshal(raw, &deliver); err != nil { + slog.ErrorContext(ctx, "xpay deliver notify decode failed", "error", err) + return wechat.BuildNotifyResponse(false) + } + if e := s.handleGoodsDeliver(ctx, &deliver, string(raw)); e != nil { + slog.ErrorContext(ctx, "xpay deliver notify handle failed", "error", e) + return wechat.BuildNotifyResponse(false) + } + return wechat.BuildNotifyResponse(true) + + case wechat.EventRefundNotify: + var refund wechat.XPayRefundNotify + if err := json.Unmarshal(raw, &refund); err != nil { + slog.ErrorContext(ctx, "xpay refund notify decode failed", "error", err) + return wechat.BuildNotifyResponse(false) + } + if e := s.handleRefundNotify(ctx, &refund); e != nil { + slog.ErrorContext(ctx, "xpay refund notify handle failed", "error", e) + return wechat.BuildNotifyResponse(false) + } + return wechat.BuildNotifyResponse(true) + + case wechat.EventIOSRefundQueryNotify: + var query wechat.XPayIOSRefundQueryNotify + if err := json.Unmarshal(raw, &query); err != nil { + slog.ErrorContext(ctx, "xpay ios refund query decode failed", "error", err) + return wechat.BuildNotifyResponse(false) + } + return s.handleIOSRefundQuery(ctx, &query) + + default: + // 投诉/代币/风控等推送:结构化记录即可,暂不实现业务。 + slog.WarnContext(ctx, "xpay notify ignored", "event", base.Event, "out_trade_no", base.OutTradeNo) + return wechat.BuildNotifyResponse(true) + } +} + +// verifyXPayOrderPaid 反查微信侧订单真实性:消息推送无签名,发货前必须通过 +// query_order(带 HMAC 签名的服务端 API)确认订单已真实支付、金额与道具一致, +// 防止伪造发货推送零成本获取权益。任一校验不通过返回错误(fail-closed,触发微信重试)。 +func (s *OrderService) verifyXPayOrderPaid(ctx context.Context, order *mysqlmodel.Order, deliver *wechat.XPayGoodsDeliverNotify) *errcode.Error { + if deliver.OpenID == "" { + slog.ErrorContext(ctx, "xpay deliver: empty openid", "order_no", order.OrderNo) + return errcode.OrderRealityCheckFailed + } + + info, err := wechat.QueryOrder(ctx, deliver.OpenID, deliver.OutTradeNo, "") + if err != nil { + slog.ErrorContext(ctx, "xpay deliver: query order failed", + "order_no", order.OrderNo, "error", err) + return errcode.OrderRealityCheckFailed + } + + // 微信侧无支付记录/未支付:伪造推送或数据异常,拒绝发放。 + if !wechat.IsOrderPaidStatus(info.Status) { + slog.ErrorContext(ctx, "xpay deliver: order not paid in wechat side", + "order_no", order.OrderNo, "query_status", info.Status) + return errcode.OrderRealityCheckFailed + } + // 反查金额与道具必须与下单快照一致(资损防线)。 + if info.PayFee != order.PayAmount { + slog.ErrorContext(ctx, "xpay deliver: query amount mismatch", + "order_no", order.OrderNo, "query_amount", info.PayFee, "order_amount", order.PayAmount) + return errcode.OrderRealityCheckFailed + } + if info.ProductID != order.XPayProductID { + slog.ErrorContext(ctx, "xpay deliver: query product mismatch", + "order_no", order.OrderNo, "query_product", info.ProductID, "order_product", order.XPayProductID) + return errcode.OrderRealityCheckFailed + } + return nil +} + +// verifyXPayRefund 反查微信侧退款真实性:退款推送无签名,回退权益前必须通过 +// query_order(带 HMAC 签名的服务端 API)确认订单在微信侧已真实退款、金额一致, +// 防止伪造退款推送回收权益。任一校验不通过返回错误(fail-closed,触发微信重试)。 +func (s *OrderService) verifyXPayRefund(ctx context.Context, order *mysqlmodel.Order, refund *wechat.XPayRefundNotify) *errcode.Error { + if refund.OpenID == "" { + slog.ErrorContext(ctx, "xpay refund: empty openid", "order_no", order.OrderNo) + return errcode.OrderRealityCheckFailed + } + + info, err := wechat.QueryOrder(ctx, refund.OpenID, refund.OutTradeNo, "") + if err != nil { + slog.ErrorContext(ctx, "xpay refund: query order failed", + "order_no", order.OrderNo, "error", err) + return errcode.OrderRealityCheckFailed + } + + // 微信侧无退款记录/未退款:伪造推送或数据异常,拒绝回退权益。 + if !wechat.IsOrderRefundedStatus(info.RefundStatus) { + slog.ErrorContext(ctx, "xpay refund: order not refunded in wechat side", + "order_no", order.OrderNo, "refund_status", info.RefundStatus) + return errcode.OrderRealityCheckFailed + } + // 反查退款金额必须与推送一致(资损防线)。 + if info.RefundFee != refund.RefundFee { + slog.ErrorContext(ctx, "xpay refund: refund amount mismatch", + "order_no", order.OrderNo, "query_refund_fee", info.RefundFee, "notify_refund_fee", refund.RefundFee) + return errcode.OrderRealityCheckFailed + } + return nil +} + +// handleGoodsDeliver 处理道具发货推送:幂等锁 + 金额/道具校验 + 条件流转 + 发放权益。 +func (s *OrderService) handleGoodsDeliver(ctx context.Context, deliver *wechat.XPayGoodsDeliverNotify, raw string) *errcode.Error { + lockKey := fmtLockKey(redismodel.KeyLockPayCallback, deliver.OutTradeNo) + lockVal := genUUID() + if ok, _ := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second); !ok { + return nil // 并发回调,交由持锁者处理。 + } + defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }() + + order, gerr := s.orderDao.OrderGetByOrderNo(ctx, deliver.OutTradeNo) + if gerr != nil { + if isNotFound(gerr) { + slog.ErrorContext(ctx, "xpay deliver: order not found", "out_trade_no", deliver.OutTradeNo) + return errcode.OrderRealityCheckFailed + } + return errcode.DatabaseError + } + + // 仅虚拟支付订单走此链路,防止与普通支付回调串单。 + if order.PayChannel != PayChannelVirtual { + slog.ErrorContext(ctx, "xpay deliver: order not virtual pay", "order_no", order.OrderNo, "pay_channel", order.PayChannel) + return errcode.OrderRealityCheckFailed + } + // 防篡改:道具 ID 与实付金额必须与下单快照一致。 + if deliver.GoodsInfo.ProductID != order.XPayProductID { + slog.ErrorContext(ctx, "xpay deliver: product mismatch", + "order_no", order.OrderNo, "notify_product", deliver.GoodsInfo.ProductID, "order_product", order.XPayProductID) + return errcode.OrderRealityCheckFailed + } + if deliver.GoodsInfo.ActualPrice != order.PayAmount { + slog.ErrorContext(ctx, "xpay deliver: amount mismatch", + "order_no", order.OrderNo, "notify_amount", deliver.GoodsInfo.ActualPrice, "order_amount", order.PayAmount) + return errcode.OrderRealityCheckFailed + } + // 防环境串单:沙箱推送不得发放现网订单。 + if int8(deliver.Env) != order.XPayEnv { + slog.ErrorContext(ctx, "xpay deliver: env mismatch", + "order_no", order.OrderNo, "notify_env", deliver.Env, "order_env", order.XPayEnv) + return errcode.OrderRealityCheckFailed + } + + // 订单真实性反查:消息推送无签名,必须反查微信侧订单确认真实支付, + // 防止伪造发货推送零成本获取权益;未通过返回错误 -> 应答 ErrCode:1,微信重试。 + if verr := s.verifyXPayOrderPaid(ctx, order, deliver); verr != nil { + return verr + } + + // 条件流转:待支付 -> 已支付,防重复回调。 + updated, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusPending, OrderStatusPaid) + if uerr != nil { + return errcode.DatabaseError + } + if updated { + s.finalizeVirtualPaidOrder(ctx, order, deliver, raw) + return nil + } + + // 流转失败:处理「订单已过期但用户实际已付款」的竞态(与普通支付 PayNotify 一致)。 + current, gerr := s.orderDao.OrderGetByOrderNo(ctx, deliver.OutTradeNo) + if gerr != nil { + if isNotFound(gerr) { + return nil + } + return errcode.DatabaseError + } + switch current.OrderStatus { + case OrderStatusPaid: + // 已被并发回调处理,兜底补发(幂等)。 + _ = s.orderDao.OrderUpdateByOrderNo(ctx, current.OrderNo, map[string]any{ + "pay_time": resolvePaidTime(deliver.WeChatPayInfo.PaidTime), + "transaction_id": deliver.WeChatPayInfo.TransactionID, + "callback_raw": raw, + }) + s.grantOrderBenefits(ctx, current) + case OrderStatusExpired: + // 订单被系统过期但用户已付款:过期 -> 已支付并补发货。 + if ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusExpired, OrderStatusPaid); uerr != nil { + return errcode.DatabaseError + } else if ok { + s.finalizeVirtualPaidOrder(ctx, current, deliver, raw) + } + case OrderStatusCancelled: + // 防御分支:历史上已取消的虚拟支付订单用户仍完成了支付,补发货避免「钱已扣、权益不到账」。 + // 当前虚拟支付订单已禁止取消,此分支兜底存量数据与并发竞态。 + if ok, uerr := s.orderDao.OrderUpdateStatusByOrderNo(ctx, deliver.OutTradeNo, OrderStatusCancelled, OrderStatusPaid); uerr != nil { + return errcode.DatabaseError + } else if ok { + s.finalizeVirtualPaidOrder(ctx, current, deliver, raw) + } + } + return nil +} + +// handleRefundNotify 处理退款推送:复用退款成功回退权益链路(settleRefundSuccess)。 +func (s *OrderService) handleRefundNotify(ctx context.Context, refund *wechat.XPayRefundNotify) *errcode.Error { + lockKey := fmtLockKey(redismodel.KeyLockRefundCallback, refund.RefundOrderID) + lockVal := genUUID() + if ok, _ := redisdao.TryLock(ctx, lockKey, lockVal, 30*time.Second); !ok { + return nil + } + defer func() { _ = redisdao.Unlock(ctx, lockKey, lockVal) }() + + // 白名单判断:仅「退款成功」才回退权益;空串/未知/中间状态仅记录,防止误回退 + // (退款推送按官方文档在退款完成时触发,状态枚举联调时以官方为准)。 + if refund.RefundStatus != wechat.XPayRefundStatusSuccess { + slog.WarnContext(ctx, "xpay refund notify not success, skip revoke", + "order_no", refund.OutTradeNo, "refund_status", refund.RefundStatus) + return nil + } + + order, gerr := s.orderDao.OrderGetByOrderNo(ctx, refund.OutTradeNo) + if gerr != nil { + if isNotFound(gerr) { + slog.ErrorContext(ctx, "xpay refund: order not found", "out_trade_no", refund.OutTradeNo) + return nil + } + return errcode.DatabaseError + } + // 已终态则幂等返回。 + if order.OrderStatus == OrderStatusRefunded { + return nil + } + + // 订单真实性反查:退款推送无签名,回退权益前必须反查微信侧确认真实退款, + // 防止伪造退款推送回收权益;未通过返回错误 -> 应答 ErrCode:1,微信重试。 + if verr := s.verifyXPayRefund(ctx, order, refund); verr != nil { + return verr + } + + // settleRefundSuccess 内部含条件流转(Paid -> Refunded),重复推送不会重复回退。 + s.settleRefundSuccess(ctx, order, refund.RefundFee) + return nil +} + +// handleIOSRefundQuery 处理 iOS 退款问询(3 秒内应答,仅本地查询)。 +// 按权益使用情况细化:权益已消耗则拒绝退款(result_code=1 + evidence),未消耗则建议退款; +// 建议退款后权益由后续退款推送 settleRefundSuccess 回收(回收链路保证「已消耗部分不回退」)。 +func (s *OrderService) handleIOSRefundQuery(ctx context.Context, query *wechat.XPayIOSRefundQueryNotify) []byte { + order, err := s.orderDao.OrderGetByOrderNo(ctx, query.OutTradeNo) + if err != nil { + slog.ErrorContext(ctx, "xpay ios refund query: order not found", "out_trade_no", query.OutTradeNo, "error", err) + return wechat.BuildIOSRefundQueryResponse(1, "order not found") + } + if order.OrderStatus == OrderStatusRefunded { + return wechat.BuildIOSRefundQueryResponse(0, "already refunded") + } + // 权益已消耗则拒绝退款,防止「已使用权益后白嫖退款」。 + if s.iosRefundBenefitsConsumed(ctx, order) { + return wechat.BuildIOSRefundQueryResponse(1, "权益已使用,不支持退款") + } + return wechat.BuildIOSRefundQueryResponse(0, order.OrderNo) +} + +// iosRefundBenefitsConsumed 判断订单发放的权益是否已被消耗(用于 iOS 退款问询拒绝策略)。 +// 额度类:Cos 凭证已用 >0 视为已消耗;会员类:会员已生效(在有效期内)视为已使用。 +func (s *OrderService) iosRefundBenefitsConsumed(ctx context.Context, order *mysqlmodel.Order) bool { + // 额度类:该订单发放的 Cos 凭证已消耗则视为已使用。 + if order.CosQuota > 0 { + grants, err := mysqldao.NewQuotaGrantDao().QuotaGrantListByUserIDAndType(ctx, order.UserID, QuotaTypeCos) + if err == nil { + for _, g := range grants { + if g.RefID == order.OrderNo && g.Used > 0 { + return true + } + } + } + } + // 会员类:会员已生效(vip_expire 在未来且非非会员),说明用户已开始享受会员权益。 + if order.MemberDays > 0 { + if user, err := s.userDao.UserGetByID(ctx, order.UserID); err == nil { + if user.VIPExpire > nowUnix() && user.VIPType != VIPTypeNone { + return true + } + } + } + return false +} + +// finalizeVirtualPaidOrder 虚拟支付发货:记录支付信息、发放权益并发送通知。 +func (s *OrderService) finalizeVirtualPaidOrder(ctx context.Context, order *mysqlmodel.Order, deliver *wechat.XPayGoodsDeliverNotify, raw string) { + _ = s.orderDao.OrderUpdateByOrderNo(ctx, order.OrderNo, map[string]any{ + "pay_time": resolvePaidTime(deliver.WeChatPayInfo.PaidTime), + "transaction_id": deliver.WeChatPayInfo.TransactionID, + "callback_raw": raw, + }) + + s.grantOrderBenefits(ctx, order) + + sendNotification(ctx, order.UserID, 0, NotificationTypeOrder, "订单支付成功", "你购买的「"+order.GoodsName+"」已到账") +} + +// resolvePaidTime 兜底支付时间:推送字段缺失/非法(<=0)时用当前时间,避免 pay_time 写入 0。 +func resolvePaidTime(paidTime int64) int64 { + if paidTime <= 0 { + return nowUnix() + } + return paidTime +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2c0980b --- /dev/null +++ b/main.go @@ -0,0 +1,349 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redismodel" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/routers" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/service" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/cos" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tokenhub" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/tracing" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/wechat" + "github.com/gin-gonic/gin" + "go.opentelemetry.io/contrib/bridges/otelslog" + "go.opentelemetry.io/otel" + "gopkg.in/natefinch/lumberjack.v2" +) + +func init() { + if err := setupSetting(); err != nil { + panic(err) + } + + // OTel 需在 logger 之前初始化,使 LoggerProvider 就绪后 slog 可桥接到 OTel Logs。 + setupOTel() + + if err := setupSlogLogger(); err != nil { + panic(err) + } + + if err := setupMysqlEngine(); err != nil { + panic(err) + } + + if err := setupRedisEngine(); err != nil { + panic(err) + } + + setupCosClient() + setupWechatPayClient() + setupVirtualPayClient() +} + +func main() { + gin.SetMode(global.ServerSetting.RunMode) + + // 启动运势后台任务:每日 0 点生成 + 启动兜底。 + tokenhub.EnsureTodayFortunes() + tokenhub.StartFortuneScheduler() + + // 启动权益发放补偿与订单超时过期后台任务。 + service.StartBenefitCompensationScheduler() + service.StartOrderExpireScheduler() + + router := routers.NewRouter() + + s := &http.Server{ + Addr: ":" + global.ServerSetting.HttpPort, + Handler: router, + ReadTimeout: time.Duration(global.ServerSetting.ReadTimeout) * time.Second, + WriteTimeout: time.Duration(global.ServerSetting.WriteTimeout) * time.Second, + + // 限制http请求头的大小为1MB + MaxHeaderBytes: 1 << 20, + } + + // 后台启动 HTTP 服务;启动失败(如端口占用)通过 channel 反馈主流程,避免进程「假存活」。 + serverErr := make(chan error, 1) + go func() { + if err := s.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + serverErr <- err + } + }() + slog.Info("server started", "port", global.ServerSetting.HttpPort) + + // 等待退出信号或服务启动错误。 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + select { + case <-quit: + slog.Info("shutting down server...") + case err := <-serverErr: + slog.Error("http server start failed", "error", err) + } + + // 优雅关闭:HTTP 与 OTel 使用独立 context,避免 HTTP 等待耗尽 OTel flush 的超时。 + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + if err := s.Shutdown(shutdownCtx); err != nil { + slog.Error("http server shutdown error", "error", err) + } + + if global.OTelShutdown != nil { + otelCtx, otelCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer otelCancel() + if err := global.OTelShutdown(otelCtx); err != nil { + slog.Error("otel shutdown error", "error", err) + } + } + slog.Info("server exited") +} + +func setupSetting() error { + setting, err := setting.NewSetting() + if err != nil { + return err + } + + err = setting.ReadSection("Server", &global.ServerSetting) + if err != nil { + return err + } + + err = setting.ReadSection("Log", &global.LogSetting) + if err != nil { + return err + } + + err = setting.ReadSection("Mysql", &global.MysqlSetting) + if err != nil { + return err + } + + err = setting.ReadSection("Redis", &global.RedisSetting) + if err != nil { + return err + } + + err = setting.ReadSection("Otel", &global.OtelSetting) + if err != nil { + return err + } + + err = setting.ReadSection("JWT", &global.JWTSetting) + if err != nil { + return err + } + // 校验 JWT 关键配置,防止密钥缺失导致可用空密钥伪造 token。 + if global.JWTSetting == nil || global.JWTSetting.SecretKey == "" { + return fmt.Errorf("JWT secret key must not be empty") + } + + err = setting.ReadSection("Cos", &global.CosSetting) + if err != nil { + return err + } + + err = setting.ReadSection("Upload", &global.UploadSetting) + if err != nil { + return err + } + if global.UploadSetting.MaxSizeMB <= 0 { + global.UploadSetting.MaxSizeMB = 20 + } + + err = setting.ReadSection("Tokenhub", &global.TokenhubSetting) + if err != nil { + return err + } + if global.TokenhubSetting.Model == "" { + global.TokenhubSetting.Model = "hy4" + } + + err = setting.ReadSection("MattingModel", &global.MattingModelSetting) + if err != nil { + return err + } + if global.MattingModelSetting.MaxRetry <= 0 { + global.MattingModelSetting.MaxRetry = 3 + } + + err = setting.ReadSection("ClassificationModel", &global.ClassificationModelSetting) + if err != nil { + return err + } + if global.ClassificationModelSetting.MaxRetry <= 0 { + global.ClassificationModelSetting.MaxRetry = 3 + } + + err = setting.ReadSection("WechatMini", &global.WechatMiniSetting) + if err != nil { + return err + } + + err = setting.ReadSection("WechatPay", &global.WechatPaySetting) + if err != nil { + return err + } + + err = setting.ReadSection("UserDefaultQuota", &global.UserDefaultQuotaSetting) + if err != nil { + return err + } + + err = setting.ReadSection("AdReward", &global.AdRewardSetting) + if err != nil { + return err + } + + err = setting.ReadSection("WechatVirtualPay", &global.VirtualPaySetting) + if err != nil { + return err + } + + + // 校验 Server 配置(时间字段已按秒解析,此处仅做存在性校验)。 + if global.ServerSetting == nil { + return fmt.Errorf("Server config section is missing") + } + + return nil +} + +func setupSlogLogger() error { + // 确保日志目录存在(lumberjack 不会自动创建父目录,目录缺失时文件日志会静默失效)。 + if err := os.MkdirAll(global.LogSetting.LogSavePath, 0o755); err != nil { + return fmt.Errorf("create log dir: %w", err) + } + + writer := &lumberjack.Logger{ + Filename: global.LogSetting.LogSavePath + "/" + global.LogSetting.LogFileName + global.LogSetting.LogFileExt, + MaxSize: global.LogSetting.MaxSize, + MaxBackups: global.LogSetting.MaxBackups, + MaxAge: global.LogSetting.MaxAge, + Compress: true, + } + + multiWriter := io.MultiWriter(writer, os.Stdout) + + level := slog.LevelInfo + switch global.LogSetting.LogLevel { + case "Debug": + level = slog.LevelDebug + case "Warn": + level = slog.LevelWarn + case "Error": + level = slog.LevelError + default: + level = slog.LevelInfo + } + + // 本地 JSON 日志(文件 + stdout),并注入 trace_id/span_id。 + localHandler := tracing.OtelContextHandler{ + Handler: slog.NewJSONHandler(multiWriter, &slog.HandlerOptions{ + AddSource: true, + Level: level, + }), + } + + // OTel 日志桥接:将 slog 记录导出到 OTel Logs(LoggerProvider 已在 setupOTel 就绪; + // 未启用 OTel 时为 no-op,不产生开销)。 + otelHandler := otelslog.NewHandler("zt-closet-backend") + + // 扇出:本地日志 + OTel 日志同时输出。 + slog.SetDefault(slog.New(tracing.NewFanoutHandler(localHandler, otelHandler))) + + return nil +} + +// setupMysqlEngine 初始化 MySQL 数据库连接。 +func setupMysqlEngine() error { + var err error + global.MysqlEngine, err = mysqlmodel.NewMysqlEngine(global.MysqlSetting) + if err != nil { + return err + } + return nil +} + +// setupRedisEngine 初始化 Redis 连接。 +func setupRedisEngine() error { + var err error + global.RedisEngine, err = redismodel.NewRedisEngine(global.RedisSetting) + if err != nil { + return err + } + return nil +} + +func setupOTel() { + tracer, shutdown, err := tracing.NewOTelProvider(global.OtelSetting) + // NewOTelProvider 在任何错误路径都会返回可用的 no-op tracer 与安全 shutdown。 + global.Tracer = tracer + global.OTelShutdown = shutdown + if err != nil { + // 此时 logger 尚未就绪,用标准输出告警;已降级为 no-op。 + fmt.Printf("[WARN] init otel failed, downgraded to no-op: %v\n", err) + } + + name := "zt-closet-backend" + if global.OtelSetting != nil && global.OtelSetting.ServiceName != "" { + name = global.OtelSetting.ServiceName + } + global.Meter = otel.Meter(name) +} + +// setupCosClient 初始化腾讯云 COS 客户端(衣物图片上传依赖)。 +// 初始化失败仅告警,不阻断服务启动。 +func setupCosClient() { + client, err := cos.NewCosClient() + if err != nil { + slog.Warn("init cos client failed, upload features may fail", "error", err) + return + } + global.CosClient = client +} + +// setupWechatPayClient 初始化微信支付客户端。 +// 未启用(Enable=false)或未配置时仅告警,不阻断服务启动(便于本地/未开通支付的开发环境)。 +func setupWechatPayClient() { + if global.WechatPaySetting == nil || !global.WechatPaySetting.Enable { + slog.Warn("wechat pay not enabled, pay features disabled") + return + } + if err := wechat.InitPayClient(global.WechatPaySetting); err != nil { + slog.Warn("init wechat pay client failed, pay features disabled", "error", err) + return + } + slog.Info("wechat pay client initialized") +} + +// setupVirtualPayClient 初始化微信小程序虚拟支付(XPay)客户端。 +// 未开通(Enable=false)或配置不完整时仅告警,不阻断服务启动;虚拟商品下单时会返回「虚拟支付未开通」。 +func setupVirtualPayClient() { + if global.VirtualPaySetting == nil || !global.VirtualPaySetting.Enable { + slog.Warn("virtual pay not enabled, virtual goods purchase disabled") + return + } + if err := wechat.InitVirtualPayClient(global.VirtualPaySetting); err != nil { + slog.Warn("init virtual pay client failed, virtual goods purchase disabled", "error", err) + return + } + env, _ := wechat.GetVirtualPayEnv() + slog.Info("virtual pay client initialized", "env", env, "env_name", map[int]string{0: "prod", 1: "sandbox"}[env]) +} diff --git a/pkg/app/response.go b/pkg/app/response.go new file mode 100644 index 0000000..e42aa93 --- /dev/null +++ b/pkg/app/response.go @@ -0,0 +1,41 @@ +package app + +import ( + "net/http" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode" + "github.com/gin-gonic/gin" +) + +// Response 统一响应包装 +type Response struct { + Ctx *gin.Context +} + +func NewResponse(c *gin.Context) *Response { + return &Response{Ctx: c} +} + +// ToResponse 成功响应 +func (r *Response) ToResponse(data interface{}) { + if data == nil { + data = gin.H{} + } + r.Ctx.JSON(http.StatusOK, gin.H{ + "code": errcode.Success.Code(), + "msg": errcode.Success.Msg(), + "data": data, + }) +} + +// ToErrorResponse 错误响应 +func (r *Response) ToErrorResponse(err *errcode.Error) { + response := gin.H{ + "code": err.Code(), + "msg": err.Msg(), + } + if details := err.Details(); len(details) > 0 { + response["details"] = details + } + r.Ctx.JSON(err.StatusCode(), response) +} diff --git a/pkg/classification/classification.go b/pkg/classification/classification.go new file mode 100644 index 0000000..cd4f8e1 --- /dev/null +++ b/pkg/classification/classification.go @@ -0,0 +1,174 @@ +// Package classification 封装衣物分类服务调用,将衣物图片上传到 Fashion-CLIP 分类服务, +// 返回结构化的衣物属性分类结果。 +package classification + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" +) + +const ( + classifyPath = "/classify" + healthzPath = "/healthz" +) + +// DetailItem 分类明细项。 +type DetailItem struct { + Label string `json:"label"` + Confidence float64 `json:"confidence"` +} + +// Details 各维度 Top-5 明细。 +type Details struct { + CategoryMain []DetailItem `json:"category_main"` + CategorySub []DetailItem `json:"category_sub"` + Style []DetailItem `json:"style"` + ColorMain []DetailItem `json:"color_main"` + Thickness []DetailItem `json:"thickness"` + Version []DetailItem `json:"version"` + Material []DetailItem `json:"material"` + Pattern []DetailItem `json:"pattern"` + SleeveType []DetailItem `json:"sleeve_type"` + ClothesType []DetailItem `json:"clothes_type"` + NecklineType []DetailItem `json:"neckline_type"` + Formality []DetailItem `json:"formality"` + Occasions []DetailItem `json:"occasions"` + VisualImpact []DetailItem `json:"visual_impact"` +} + +// ClassifyResult 衣物分类结果。 +type ClassifyResult struct { + CategoryMain string `json:"category_main"` + CategoryMainConfidence float64 `json:"category_main_confidence"` + CategorySub string `json:"category_sub"` + CategorySubConfidence float64 `json:"category_sub_confidence"` + Style string `json:"style"` + StyleConfidence float64 `json:"style_confidence"` + ColorMain string `json:"color_main"` + ColorMainConfidence float64 `json:"color_main_confidence"` + ColorSub string `json:"color_sub"` + ColorSubConfidence float64 `json:"color_sub_confidence"` + Thickness string `json:"thickness"` + ThicknessConfidence float64 `json:"thickness_confidence"` + Version string `json:"version"` + VersionConfidence float64 `json:"version_confidence"` + Material string `json:"material"` + MaterialConfidence float64 `json:"material_confidence"` + Pattern string `json:"pattern"` + PatternConfidence float64 `json:"pattern_confidence"` + SleeveType string `json:"sleeve_type"` + SleeveTypeConfidence float64 `json:"sleeve_type_confidence"` + ClothesType string `json:"clothes_type"` + ClothesTypeConfidence float64 `json:"clothes_type_confidence"` + NecklineType string `json:"neckline_type"` + NecklineTypeConfidence float64 `json:"neckline_type_confidence"` + Formality string `json:"formality"` + FormalityLabel string `json:"formality_label"` + FormalityConfidence float64 `json:"formality_confidence"` + Occasions string `json:"occasions"` + VisualImpact string `json:"visual_impact"` + VisualImpactLabel string `json:"visual_impact_label"` + VisualImpactConfidence float64 `json:"visual_impact_confidence"` + Details Details `json:"details"` +} + +// Client 分类服务客户端。 +type Client struct { + baseURL string + httpClient *http.Client +} + +// NewClient 从全局配置创建客户端。 +func NewClient() *Client { + return NewClientWithAddr(global.ClassificationModelSetting.Host, global.ClassificationModelSetting.Port) +} + +// NewClientWithAddr 使用指定 Host/Port 创建客户端,便于测试与解耦。 +func NewClientWithAddr(host string, port int) *Client { + return &Client{ + baseURL: fmt.Sprintf("http://%s:%d", host, port), + httpClient: &http.Client{ + Timeout: 120 * time.Second, + }, + } +} + +// Classify 调用分类服务,上传图片并返回结构化的衣物分类结果。 +func (c *Client) Classify(ctx context.Context, imageData []byte) (*ClassifyResult, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + part, err := writer.CreateFormFile("file", "image.png") + if err != nil { + return nil, fmt.Errorf("classify create form file: %w", err) + } + if _, err := part.Write(imageData); err != nil { + return nil, fmt.Errorf("classify write image: %w", err) + } + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("classify close multipart: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+classifyPath, &body) + if err != nil { + return nil, fmt.Errorf("classify create request: %w", err) + } + request.Header.Set("Content-Type", writer.FormDataContentType()) + + response, err := c.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("classify do request: %w", err) + } + defer response.Body.Close() + + data, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("classify read response: %w", err) + } + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("classify api error: status=%d, body=%s", response.StatusCode, summarize(data)) + } + + var result ClassifyResult + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("classify decode response: %w", err) + } + return &result, nil +} + +// HealthCheck 检查分类服务是否就绪。 +func (c *Client) HealthCheck(ctx context.Context) error { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+healthzPath, nil) + if err != nil { + return fmt.Errorf("classify create healthz request: %w", err) + } + + response, err := c.httpClient.Do(request) + if err != nil { + return fmt.Errorf("classify healthz request: %w", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return fmt.Errorf("classify healthz status: %d", response.StatusCode) + } + return nil +} + +// summarize 截断响应体摘要,避免错误信息打印大体积 JSON。 +func summarize(data []byte) string { + const maxLen = 512 + if len(data) > maxLen { + return string(data[:maxLen]) + "..." + } + return string(data) +} diff --git a/pkg/classification/classification_test.go b/pkg/classification/classification_test.go new file mode 100644 index 0000000..c23dc22 --- /dev/null +++ b/pkg/classification/classification_test.go @@ -0,0 +1,65 @@ +package classification + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +// TestClassify 使用仓库根目录的 image.png 调用真实分类服务进行端到端验证。 +func TestClassify(t *testing.T) { + client := NewClientWithAddr("127.0.0.1", 8000) + ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) + defer cancel() + + // 前置:确认分类服务已就绪(模型加载完成后 /healthz 才会响应) + if err := client.HealthCheck(ctx); err != nil { + t.Skipf("分类服务未就绪,跳过真实分类测试: %v", err) + } + + imgPath := filepath.Join("..", "..", "image.png") + raw, err := os.ReadFile(imgPath) + if err != nil { + t.Fatalf("读取 image.png 失败: %v", err) + } + + result, err := client.Classify(ctx, raw) + if err != nil { + t.Fatalf("Classify 失败: %v", err) + } + + if result == nil { + t.Fatal("分类返回空结果") + } + if result.CategoryMain == "" { + t.Fatal("分类大类 category_main 为空") + } + if !validLevel(result.Formality) { + t.Fatalf("正式程度 formality 应为 1~5,实际 %q", result.Formality) + } + if !validLevel(result.VisualImpact) { + t.Fatalf("视觉冲击力 visual_impact 应为 1~5,实际 %q", result.VisualImpact) + } + + t.Logf("分类成功: category_main=%s(%.3f), category_sub=%s(%.3f), style=%s(%.3f), color_main=%s(%.3f)/%s(%.3f), formality=%s(%s), visual_impact=%s(%s), occasions=%s", + result.CategoryMain, result.CategoryMainConfidence, + result.CategorySub, result.CategorySubConfidence, + result.Style, result.StyleConfidence, + result.ColorMain, result.ColorMainConfidence, + result.ColorSub, result.ColorSubConfidence, + result.Formality, result.FormalityLabel, + result.VisualImpact, result.VisualImpactLabel, + result.Occasions) +} + +// validLevel 校验 1-5 等级字符串是否合法。 +func validLevel(s string) bool { + switch s { + case "1", "2", "3", "4", "5": + return true + default: + return false + } +} diff --git a/pkg/cos/cosclient.go b/pkg/cos/cosclient.go new file mode 100644 index 0000000..e90f195 --- /dev/null +++ b/pkg/cos/cosclient.go @@ -0,0 +1,26 @@ +package cos + +import ( + "fmt" + "net/http" + "net/url" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "github.com/tencentyun/cos-go-sdk-v5" +) + +func NewCosClient() (*cos.Client, error) { + u, err := url.Parse("https://" + global.CosSetting.CosUploadDomain) + if err != nil { + return nil, fmt.Errorf("parse cos upload domain: %w", err) + } + b := &cos.BaseURL{BucketURL: u} + client := cos.NewClient(b, &http.Client{ + Transport: &cos.AuthorizationTransport{ + SecretID: global.CosSetting.CosSecretID, + SecretKey: global.CosSetting.CosSecretKey, + }, + }) + + return client, nil +} diff --git a/pkg/errcode/clothes_code.go b/pkg/errcode/clothes_code.go new file mode 100644 index 0000000..23a9184 --- /dev/null +++ b/pkg/errcode/clothes_code.go @@ -0,0 +1,11 @@ +package errcode + +// 衣物相关错误码段位:50000 - 59999 +var ( + ClothesNotFound = NewError(50000, "衣物不存在") + ClothesInUse = NewError(50001, "衣服已被搭配使用,无法删除") + ClothesCreateFailed = NewError(50002, "创建衣物失败") + ClothesDeleteFailed = NewError(50003, "删除衣物失败") + ClothesUpdateFailed = NewError(50004, "更新衣物失败") + ClothesNoPermission = NewError(50005, "无权操作该衣物") +) diff --git a/pkg/errcode/common_code.go b/pkg/errcode/common_code.go new file mode 100644 index 0000000..981b178 --- /dev/null +++ b/pkg/errcode/common_code.go @@ -0,0 +1,43 @@ +package errcode + +// 公共错误码段位:10000 - 19999 +// +// 细分: +// 10000 - 10999 通用 / 参数错误 +// 11000 - 11999 鉴权 / Token +// 12000 - 12999 权限 / 资源 +// 19000 - 19999 系统级错误(DB、缓存、RPC) + +// 成功 +var Success = NewError(0, "成功") + +// ===== 通用错误(10000 - 10999) ===== +var ( + InvalidParams = NewError(10000, "请求参数错误") + NotFound = NewError(10001, "资源不存在") + MethodNotAllow = NewError(10002, "请求方法不允许") + TooManyRequests = NewError(10003, "请求过于频繁") +) + +// ===== 鉴权 / Token(11000 - 11999) ===== +var ( + Unauthorized = NewError(11000, "未授权") + UnauthorizedTokenError = NewError(11001, "Token 鉴权失败") + UnauthorizedTokenTimeout = NewError(11002, "Token 已过期") + UnauthorizedTokenGenerate = NewError(11003, "Token 生成失败") + UnauthorizedTokenMissing = NewError(11004, "Token 缺失") +) + +// ===== 权限(12000 - 12999) ===== +var ( + Forbidden = NewError(12000, "无权限访问") +) + +// ===== 系统错误(19000 - 19999) ===== +var ( + ServerError = NewError(19000, "服务内部错误") + DatabaseError = NewError(19001, "数据库错误") + CacheError = NewError(19002, "缓存错误") + RPCError = NewError(19003, "下游服务调用失败") + UnknownError = NewError(19999, "未知错误") +) diff --git a/pkg/errcode/errcode.go b/pkg/errcode/errcode.go new file mode 100644 index 0000000..9aa094e --- /dev/null +++ b/pkg/errcode/errcode.go @@ -0,0 +1,79 @@ +package errcode + +import ( + "fmt" + "net/http" +) + +// Error 业务错误 +type Error struct { + code int + msg string + details []string +} + +// codes 用于注册时去重检查 +var codes = map[int]string{} + +// NewError 注册并创建一个错误码 +func NewError(code int, msg string) *Error { + if _, exists := codes[code]; exists { + panic(fmt.Sprintf("error code %d already exists, please change", code)) + } + codes[code] = msg + return &Error{code: code, msg: msg} +} + +func (e *Error) Error() string { + return fmt.Sprintf("error code: %d, msg: %s", e.code, e.msg) +} + +func (e *Error) Code() int { return e.code } +func (e *Error) Msg() string { return e.msg } +func (e *Error) Details() []string { return e.details } + +func (e *Error) Msgf(args ...interface{}) string { + return fmt.Sprintf(e.msg, args...) +} + +// WithDetails 附加详细信息(返回副本) +func (e *Error) WithDetails(details ...string) *Error { + newError := *e + newError.details = append(newError.details, details...) + return &newError +} + +// StatusCode 业务码 → HTTP 状态码 +// 仅对少数特殊错误返回非 200,其余统一 200,由前端按业务 code 处理 +func (e *Error) StatusCode() int { + switch e.code { + case Success.Code(): + return http.StatusOK + + // 鉴权类返回 401,便于前端拦截器统一处理跳转登录 + case Unauthorized.Code(), + UnauthorizedTokenError.Code(), + UnauthorizedTokenTimeout.Code(), + UnauthorizedTokenMissing.Code(): + return http.StatusUnauthorized + + // 越权/无权限返回 403,便于网关与审计识别越权事件 + case Forbidden.Code(): + return http.StatusForbidden + + // 限流返回 429,便于网关识别限流事件 + case TooManyRequests.Code(): + return http.StatusTooManyRequests + + // 系统错误返回 500,便于运维监控告警 + case ServerError.Code(), + DatabaseError.Code(), + CacheError.Code(), + RPCError.Code(), + UnknownError.Code(), + MattingFailed.Code(), + ClassifyFailed.Code(): + return http.StatusInternalServerError + } + return http.StatusOK +} diff --git a/pkg/errcode/goods_code.go b/pkg/errcode/goods_code.go new file mode 100644 index 0000000..ec0033a --- /dev/null +++ b/pkg/errcode/goods_code.go @@ -0,0 +1,13 @@ +package errcode + +// 商品模块错误码段位:60000 - 69999 +// +// 60000 - 60999 业务错误 +// 69000 - 69999 服务端错误 +var ( + GoodsNotFound = NewError(60000, "商品不存在") + GoodsOffShelf = NewError(60001, "商品已下架") + GoodsCreateFailed = NewError(69000, "创建商品失败") + GoodsUpdateFailed = NewError(69001, "更新商品失败") + GoodsDeleteFailed = NewError(69002, "删除商品失败") +) diff --git a/pkg/errcode/misc_code.go b/pkg/errcode/misc_code.go new file mode 100644 index 0000000..cf00bc4 --- /dev/null +++ b/pkg/errcode/misc_code.go @@ -0,0 +1,24 @@ +package errcode + +// 通知/分享/社区/星座/AI 模块错误码段位:90000 - 99999 +var ( + // 通知(90000 - 90099) + NotificationNotFound = NewError(90000, "通知不存在") + + // 分享(90100 - 90199) + ShareNotFound = NewError(90100, "分享不存在") + ShareExpired = NewError(90101, "分享已过期") + ShareCancelled = NewError(90102, "分享已取消") + + // 点赞/收藏/社区(90200 - 90299) + TargetNotFound = NewError(90200, "目标内容不存在") + LikeFailed = NewError(90201, "点赞操作失败") + CollectFailed = NewError(90202, "收藏操作失败") + + // 星座(90300 - 90399) + HoroscopeNotFound = NewError(90300, "星座运势不存在") + + // AI 推荐(90400 - 90499) + AIRecommendFailed = NewError(90400, "AI 推荐失败") + AIRecommendQuotaLimit = NewError(90401, "AI 推荐次数已达上限") +) diff --git a/pkg/errcode/order_code.go b/pkg/errcode/order_code.go new file mode 100644 index 0000000..aebe956 --- /dev/null +++ b/pkg/errcode/order_code.go @@ -0,0 +1,31 @@ +package errcode + +// 订单模块错误码段位:70000 - 70999 +// 退款子模块:71000 - 71999 +// 服务端错误:79000 - 79999 +var ( + // 订单 + OrderNotFound = NewError(70000, "订单不存在") + OrderStatusInvalid = NewError(70001, "订单状态不正确") + OrderAlreadyPaid = NewError(70002, "订单已支付") + OrderAlreadyCancelled = NewError(70003, "订单已取消") + OrderExpired = NewError(70004, "订单已过期") + OrderCreateFailed = NewError(79000, "创建订单失败") + OrderCancelFailed = NewError(79001, "取消订单失败") + OrderUpdateFailed = NewError(79002, "更新订单失败") + + // 虚拟支付 + VirtualPayNotEnabled = NewError(70005, "虚拟支付未开通") + VirtualPayGoodsNotPublished = NewError(70006, "商品尚未发布为虚拟支付道具") + VirtualOrderCancelNotAllowed = NewError(70007, "虚拟支付订单不支持取消") + VirtualPayAmountTooLow = NewError(70008, "iOS 虚拟支付最低 1 元") + OrderRealityCheckFailed = NewError(70009, "订单真实性校验失败") + VirtualOrderCreateFailed = NewError(79003, "创建虚拟支付订单失败") + + // 退款 + RefundNotFound = NewError(71000, "退款记录不存在") + RefundNotAllowed = NewError(71001, "当前状态不允许退款") + RefundAmountInvalid = NewError(71002, "退款金额不合法") + RefundApplyFailed = NewError(71003, "退款申请失败") + IOSRefundViaAppStore = NewError(71004, "iOS 订单请通过 App Store 申请退款") +) diff --git a/pkg/errcode/outfit_code.go b/pkg/errcode/outfit_code.go new file mode 100644 index 0000000..e0e3469 --- /dev/null +++ b/pkg/errcode/outfit_code.go @@ -0,0 +1,10 @@ +package errcode + +// 穿搭相关错误码段位:40000 - 49999 +var ( + OutfitNotFound = NewError(40000, "穿搭不存在") + OutfitCreateFailed = NewError(40001, "创建穿搭失败") + OutfitDeleteFailed = NewError(40002, "删除穿搭失败") + OutfitUpdateFailed = NewError(40003, "更新穿搭失败") + OutfitNoPermission = NewError(40004, "无权操作该穿搭") +) diff --git a/pkg/errcode/quota_code.go b/pkg/errcode/quota_code.go new file mode 100644 index 0000000..8072067 --- /dev/null +++ b/pkg/errcode/quota_code.go @@ -0,0 +1,19 @@ +package errcode + +// 额度模块错误码段位:80000 - 80999 +// VIP 子模块:81000 - 81999 +// 服务端错误:89000 - 89999 +var ( + // 额度 + QuotaInsufficient = NewError(80000, "额度不足") + QuotaNotFound = NewError(80001, "额度记录不存在") + AdRewardClaimed = NewError(80002, "广告激励已领取") + AdRewardInvalid = NewError(80003, "无效的激励类型") + AdRewardDisabled = NewError(80004, "广告激励未开启") + QuotaUpdateFailed = NewError(89000, "额度更新失败") + + // VIP + VIPNotFound = NewError(81000, "会员记录不存在") + VIPExpired = NewError(81001, "会员已过期") + VIPCreateFailed = NewError(81002, "会员开通失败") +) diff --git a/pkg/errcode/upload_code.go b/pkg/errcode/upload_code.go new file mode 100644 index 0000000..dcf4b25 --- /dev/null +++ b/pkg/errcode/upload_code.go @@ -0,0 +1,18 @@ +package errcode + +// 上传模块错误码段位:30000-39999 +// 30000-30999 参数/校验 +// 39000-39999 服务端错误 + +var ( + UploadFileEmpty = NewError(30000, "上传文件为空") + UploadFileExtNotAllow = NewError(30001, "不支持的文件类型") + UploadFileTooLarge = NewError(30002, "文件大小超过限制") + UploadFileNotFound = NewError(30004, "文件不存在") + UploadFileSaveFailed = NewError(39000, "文件保存失败") + FileNotPublic = NewError(30005, "文件不是公开的") + FileNoPermission = NewError(30006, "权限不足") + FileProcessing = NewError(30007, "文件处理中") + MattingFailed = NewError(39001, "图片抠图处理失败") + ClassifyFailed = NewError(39002, "图片分类处理失败") +) diff --git a/pkg/errcode/user_code.go b/pkg/errcode/user_code.go new file mode 100644 index 0000000..430e321 --- /dev/null +++ b/pkg/errcode/user_code.go @@ -0,0 +1,43 @@ +package errcode + +// 用户模块错误码段位:20000 - 29999 +// +// 细分: +// 20000 - 20999 参数错误 +// 21000 - 21999 鉴权 / 登录 +// 22000 - 22999 资源不存在 +// 23000 - 23999 冲突 / 重复 +// 29000 - 29999 服务端错误 + +// ===== 参数错误(20000 - 20999) ===== +var ( + UserParamsInvalid = NewError(20000, "用户参数错误") +) + +// ===== 鉴权 / 登录(21000 - 21999) ===== +var ( + UserLoginFailed = NewError(21000, "用户名或密码错误") + UserDisabled = NewError(21001, "账号已被禁用") + UserWxLoginFailed = NewError(21002, "微信登录失败") + UserWxCodeInvalid = NewError(21003, "微信 Code 无效") + UserRegistrationDisabled = NewError(21004, "注册暂未开放") +) + +// ===== 资源不存在(22000 - 22999) ===== +var ( + UserNotFound = NewError(22000, "用户不存在") +) + +// ===== 冲突(23000 - 23999) ===== +var ( + UserAlreadyExists = NewError(23000, "用户已存在") + UserEmailExists = NewError(23001, "邮箱已被注册") +) + +// ===== 服务端错误(29000 - 29999) ===== +var ( + UserCreateFailed = NewError(29000, "用户创建失败") + UserUpdateFailed = NewError(29001, "用户更新失败") + UserDeleteFailed = NewError(29002, "用户删除失败") + UserQueryFailed = NewError(29003, "用户查询失败") +) diff --git a/pkg/lzcwyjwt/lzcwyjwt.go b/pkg/lzcwyjwt/lzcwyjwt.go new file mode 100644 index 0000000..009416e --- /dev/null +++ b/pkg/lzcwyjwt/lzcwyjwt.go @@ -0,0 +1,137 @@ +package lzcwyjwt + +import ( + "errors" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "github.com/golang-jwt/jwt/v5" +) + +type TokenType string + +const ( + TokenTypeAccess TokenType = "access" + TokenTypeRefresh TokenType = "refresh" +) + +type Claims struct { + UserID int64 `json:"user_id"` + TokenType TokenType `json:"token_type"` + jwt.RegisteredClaims +} + +var ( + ErrTokenInvalid = errors.New("token invalid") + ErrTokenExpired = errors.New("token expired") + ErrTokenWrongType = errors.New("token type mismatch") +) + +func GenerateTokens(userID int64) (accessToken, refreshToken string, err error) { + now := time.Now() + accessClaims := Claims{ + UserID: userID, + TokenType: TokenTypeAccess, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: global.JWTSetting.Issuer, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(global.JWTSetting.AccessTokenExpire) * time.Second)), + NotBefore: jwt.NewNumericDate(now), + }, + } + + refreshClaims := Claims{ + UserID: userID, + TokenType: TokenTypeRefresh, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: global.JWTSetting.Issuer, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(global.JWTSetting.RefreshTokenExpire) * time.Second)), + NotBefore: jwt.NewNumericDate(now), + }, + } + + accessToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(global.JWTSetting.SecretKey)) + if err != nil { + return + } + + refreshToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(global.JWTSetting.SecretKey)) + if err != nil { + return + } + + return accessToken, refreshToken, nil +} + +func ParseAccessToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) { + // 验证签名算法 + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrTokenInvalid + } + return []byte(global.JWTSetting.SecretKey), nil + }) + + if err != nil { + // 区分过期和其他错误 + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrTokenExpired + } + return nil, err + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, ErrTokenInvalid + } + + // 校验签发方,防止跨服务 token 混用。 + if global.JWTSetting.Issuer != "" && claims.Issuer != global.JWTSetting.Issuer { + return nil, ErrTokenInvalid + } + + // 校验 token 类型(防止 refresh token 被当 access token 用) + if claims.TokenType != TokenTypeAccess { + return nil, ErrTokenWrongType + } + + return claims, nil + +} + +func ParseRefreshToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) { + // 验证签名算法 + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrTokenInvalid + } + return []byte(global.JWTSetting.SecretKey), nil + }) + + if err != nil { + // 区分过期和其他错误 + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrTokenExpired + } + return nil, err + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, ErrTokenInvalid + } + + // 校验签发方,防止跨服务 token 混用。 + if global.JWTSetting.Issuer != "" && claims.Issuer != global.JWTSetting.Issuer { + return nil, ErrTokenInvalid + } + + // 校验 token 类型(防止 access token 被当 refresh token 用) + if claims.TokenType != TokenTypeRefresh { + return nil, ErrTokenWrongType + } + + return claims, nil + +} diff --git a/pkg/matting/matting.go b/pkg/matting/matting.go new file mode 100644 index 0000000..6848504 --- /dev/null +++ b/pkg/matting/matting.go @@ -0,0 +1,109 @@ +// Package matting 封装图片抠图服务调用,将衣物图片上传到 BiRefNet 抠图服务, +// 返回透明底 PNG 图片字节。 +package matting + +import ( + "bytes" + "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" +) + +const ( + mattingPath = "/matting" + healthzPath = "/healthz" +) + +// Client 抠图服务客户端。 +type Client struct { + baseURL string + httpClient *http.Client +} + +// NewClient 从全局配置创建客户端。 +func NewClient() *Client { + return NewClientWithAddr(global.MattingModelSetting.Host, global.MattingModelSetting.Port) +} + +// NewClientWithAddr 使用指定 Host/Port 创建客户端,便于测试与解耦。 +func NewClientWithAddr(host string, port int) *Client { + return &Client{ + baseURL: fmt.Sprintf("http://%s:%d", host, port), + httpClient: &http.Client{ + Timeout: 120 * time.Second, + }, + } +} + +// Matting 调用抠图服务,将图片上传并返回透明底 PNG 字节。 +func (c *Client) Matting(ctx context.Context, imageData []byte) ([]byte, error) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + part, err := writer.CreateFormFile("file", "image.png") + if err != nil { + return nil, fmt.Errorf("matting create form file: %w", err) + } + if _, err := part.Write(imageData); err != nil { + return nil, fmt.Errorf("matting write image: %w", err) + } + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("matting close multipart: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+mattingPath, &body) + if err != nil { + return nil, fmt.Errorf("matting create request: %w", err) + } + request.Header.Set("Content-Type", writer.FormDataContentType()) + + response, err := c.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("matting do request: %w", err) + } + defer response.Body.Close() + + data, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("matting read response: %w", err) + } + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("matting api error: status=%d, body=%s", response.StatusCode, summarize(data)) + } + + return data, nil +} + +// HealthCheck 检查抠图服务是否就绪。 +func (c *Client) HealthCheck(ctx context.Context) error { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+healthzPath, nil) + if err != nil { + return fmt.Errorf("matting create healthz request: %w", err) + } + + response, err := c.httpClient.Do(request) + if err != nil { + return fmt.Errorf("matting healthz request: %w", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return fmt.Errorf("matting healthz status: %d", response.StatusCode) + } + return nil +} + +// summarize 截断响应体摘要,避免错误信息打印大体积二进制。 +func summarize(data []byte) string { + const maxLen = 512 + if len(data) > maxLen { + return string(data[:maxLen]) + "..." + } + return string(data) +} diff --git a/pkg/matting/matting_test.go b/pkg/matting/matting_test.go new file mode 100644 index 0000000..ed87c42 --- /dev/null +++ b/pkg/matting/matting_test.go @@ -0,0 +1,53 @@ +package matting + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" +) + +// TestMatting 使用仓库根目录的 image.png 调用真实抠图服务进行端到端验证。 +func TestMatting(t *testing.T) { + client := NewClientWithAddr("127.0.0.1", 8001) + ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) + defer cancel() + + // 前置:确认抠图服务已就绪(模型加载完成后 /healthz 才会响应) + if err := client.HealthCheck(ctx); err != nil { + t.Skipf("抠图服务未就绪,跳过真实抠图测试: %v", err) + } + + imgPath := filepath.Join("..", "..", "image.png") + raw, err := os.ReadFile(imgPath) + if err != nil { + t.Fatalf("读取 image.png 失败: %v", err) + } + + out, err := client.Matting(ctx, raw) + if err != nil { + t.Fatalf("Matting 失败: %v", err) + } + + if len(out) == 0 { + t.Fatal("抠图返回空结果") + } + + pngMagic := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} + if len(out) < len(pngMagic) || !bytes.Equal(out[:len(pngMagic)], pngMagic) { + preview := out + if len(preview) > len(pngMagic) { + preview = preview[:len(pngMagic)] + } + t.Fatalf("返回结果不是 PNG,前 %d 字节: % x", len(preview), preview) + } + + // 保存结果便于人工核验 + resultPath := filepath.Join(t.TempDir(), "matting_result.png") + if err := os.WriteFile(resultPath, out, 0o644); err != nil { + t.Fatalf("保存抠图结果失败: %v", err) + } + t.Logf("抠图成功,输入 %d 字节 -> 输出 %d 字节,结果已保存到 %s", len(raw), len(out), resultPath) +} diff --git a/pkg/migration/000001_create_users.down.sql b/pkg/migration/000001_create_users.down.sql new file mode 100644 index 0000000..5f0853a --- /dev/null +++ b/pkg/migration/000001_create_users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `users`; diff --git a/pkg/migration/000001_create_users.up.sql b/pkg/migration/000001_create_users.up.sql new file mode 100644 index 0000000..b4303a7 --- /dev/null +++ b/pkg/migration/000001_create_users.up.sql @@ -0,0 +1,28 @@ +CREATE TABLE `users` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `user_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户UUID', + `wx_open_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信openid', + `username` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '用户名', + `avatar` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '头像存储位置', + `sex` TINYINT NOT NULL DEFAULT 0 COMMENT '性别 1=男 2=女', + `birth_day` BIGINT NOT NULL DEFAULT 0 COMMENT '生日', + `phone` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '手机号', + `is_new_user` TINYINT NOT NULL DEFAULT 0 COMMENT '是否新用户 0=否 1=是', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=正常 2=临时禁用 3=永久禁用 4=已注销', + `ban_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '封禁原因', + `ban_start` BIGINT NOT NULL DEFAULT 0 COMMENT '封禁开始时间', + `ban_expire` BIGINT NOT NULL DEFAULT 0 COMMENT '封禁到期时间', + `vip_type` TINYINT NOT NULL DEFAULT 0 COMMENT 'VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员', + `vip_start` BIGINT NOT NULL DEFAULT 0 COMMENT 'VIP开始时间', + `vip_expire` BIGINT NOT NULL DEFAULT 0 COMMENT 'VIP到期时间', + `last_login_time` BIGINT NOT NULL DEFAULT 0 COMMENT '最近登录时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_wx_open_id` (`wx_open_id`), + KEY `idx_users_user_uuid` (`user_uuid`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户表'; diff --git a/pkg/migration/000002_create_clothes.down.sql b/pkg/migration/000002_create_clothes.down.sql new file mode 100644 index 0000000..2fe1179 --- /dev/null +++ b/pkg/migration/000002_create_clothes.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `clothes`; diff --git a/pkg/migration/000002_create_clothes.up.sql b/pkg/migration/000002_create_clothes.up.sql new file mode 100644 index 0000000..b5a72f5 --- /dev/null +++ b/pkg/migration/000002_create_clothes.up.sql @@ -0,0 +1,36 @@ +CREATE TABLE `clothes` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `clothes_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣物UUID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属用户ID', + `category_main` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣物大类 上装/下装/连衣裙/外套/鞋/配饰', + `category_sub` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣物小类 T恤/牛仔裤/运动鞋等', + `style` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '风格', + `color` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '主颜色', + `color_sub` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '副颜色', + `thickness` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '厚度', + `version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '版型 宽松/修身/阔腿/紧身/常规合身', + `material` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '材质 牛仔布/针织/皮质/纯棉/羊毛等', + `pattern` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '花纹', + `sleeve_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '袖型', + `clothes_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '衣长类型 短款/常规/中长/长款', + `neckline_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '领型', + `formality` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '正式程度 1-5', + `occasions` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '适用场合', + `visual_impact` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '视觉冲击力 1-5', + `name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '名称', + `remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注', + `processed_image` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '处理后图片存储位置 相对路径', + `image_size` BIGINT NOT NULL DEFAULT 0 COMMENT '图片大小 单位:kb', + `image_process_status` TINYINT NOT NULL DEFAULT 0 COMMENT '处理状态 1=抠图中 2=分类中 3=排队中 4=成功 5=失败', + `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_clothes_uuid` (`clothes_uuid`), + KEY `idx_clothes_user_category` (`user_id`, `category_main`), + KEY `idx_clothes_user_status` (`user_id`, `image_process_status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣物表'; diff --git a/pkg/migration/000003_create_outfits.down.sql b/pkg/migration/000003_create_outfits.down.sql new file mode 100644 index 0000000..c73e9e0 --- /dev/null +++ b/pkg/migration/000003_create_outfits.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS `outfit_items`; +DROP TABLE IF EXISTS `outfits`; diff --git a/pkg/migration/000003_create_outfits.up.sql b/pkg/migration/000003_create_outfits.up.sql new file mode 100644 index 0000000..eeda050 --- /dev/null +++ b/pkg/migration/000003_create_outfits.up.sql @@ -0,0 +1,37 @@ +CREATE TABLE `outfits` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `outfit_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '穿搭UUID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属用户ID', + `outfit_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '穿搭名称', + `outfit_desc` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '穿搭描述', + `scene` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '适合场景', + `source` TINYINT NOT NULL DEFAULT 0 COMMENT '来源 1=用户创建 2=AI推荐', + `is_public` TINYINT NOT NULL DEFAULT 0 COMMENT '是否公开 1=不公开 2=公开', + `audit_status` TINYINT NOT NULL DEFAULT 0 COMMENT '审核状态 1=待审核 2=审核通过 3=审核不通过', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=正常 2=封禁 3=待用户保存', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_outfits_uuid` (`outfit_uuid`), + KEY `idx_outfits_user_status` (`user_id`, `status`), + KEY `idx_outfits_public` (`is_public`, `audit_status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭表'; + +CREATE TABLE `outfit_items` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `outfit_id` BIGINT NOT NULL DEFAULT 0 COMMENT '穿搭ID', + `clothes_id` BIGINT NOT NULL DEFAULT 0 COMMENT '衣物ID', + PRIMARY KEY (`id`), + KEY `idx_outfit_items_outfit` (`outfit_id`), + KEY `idx_outfit_items_clothes` (`clothes_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭明细表'; diff --git a/pkg/migration/000004_create_closet_shares.down.sql b/pkg/migration/000004_create_closet_shares.down.sql new file mode 100644 index 0000000..1d4d4c7 --- /dev/null +++ b/pkg/migration/000004_create_closet_shares.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS `closet_share_items`; +DROP TABLE IF EXISTS `closet_shares`; diff --git a/pkg/migration/000004_create_closet_shares.up.sql b/pkg/migration/000004_create_closet_shares.up.sql new file mode 100644 index 0000000..74d3f49 --- /dev/null +++ b/pkg/migration/000004_create_closet_shares.up.sql @@ -0,0 +1,31 @@ +CREATE TABLE `closet_shares` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '所属用户ID', + `share_type` TINYINT NOT NULL DEFAULT 0 COMMENT '分享类型 1=部分衣物分享 2=全量快照分享', + `expire_time` BIGINT NOT NULL DEFAULT 0 COMMENT '过期时间', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '分享状态 1=正常 2=用户取消 3=已过期 4=管理员封禁', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_closet_shares_uuid` (`share_uuid`), + KEY `idx_closet_shares_user_status` (`user_id`, `status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享表'; + +CREATE TABLE `closet_share_items` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID', + `clothes_id` BIGINT NOT NULL DEFAULT 0 COMMENT '衣物ID', + PRIMARY KEY (`id`), + KEY `idx_closet_share_items_share` (`share_uuid`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享明细表'; diff --git a/pkg/migration/000005_create_user_quotas.down.sql b/pkg/migration/000005_create_user_quotas.down.sql new file mode 100644 index 0000000..61d1166 --- /dev/null +++ b/pkg/migration/000005_create_user_quotas.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS `user_daily_usages`; +DROP TABLE IF EXISTS `user_quotas`; diff --git a/pkg/migration/000005_create_user_quotas.up.sql b/pkg/migration/000005_create_user_quotas.up.sql new file mode 100644 index 0000000..5186d1d --- /dev/null +++ b/pkg/migration/000005_create_user_quotas.up.sql @@ -0,0 +1,32 @@ +CREATE TABLE `user_quotas` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', + `quota_type` TINYINT NOT NULL DEFAULT 0 COMMENT '额度类型 1=cos上传 2=AI推荐 3=社区分享 4=衣柜分享', + `total` BIGINT NOT NULL DEFAULT 0 COMMENT '总配额', + `used` BIGINT NOT NULL DEFAULT 0 COMMENT '已用量', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_quota` (`user_id`, `quota_type`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户额度表'; + +CREATE TABLE `user_daily_usages` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', + `stat_date` BIGINT NOT NULL DEFAULT 0 COMMENT '统计日期(YYYYMMDD)', + `ai_recommend_count` BIGINT NOT NULL DEFAULT 0 COMMENT '当日AI推荐次数', + `outfit_share_count` BIGINT NOT NULL DEFAULT 0 COMMENT '当日社区分享次数', + `closet_share_count` BIGINT NOT NULL DEFAULT 0 COMMENT '当日衣柜分享次数', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_daily_usage_user_date` (`user_id`, `stat_date`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户每日用量统计表'; diff --git a/pkg/migration/000006_create_quota_flow_logs.down.sql b/pkg/migration/000006_create_quota_flow_logs.down.sql new file mode 100644 index 0000000..8e81ed3 --- /dev/null +++ b/pkg/migration/000006_create_quota_flow_logs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `quota_flow_logs`; diff --git a/pkg/migration/000006_create_quota_flow_logs.up.sql b/pkg/migration/000006_create_quota_flow_logs.up.sql new file mode 100644 index 0000000..8cce07c --- /dev/null +++ b/pkg/migration/000006_create_quota_flow_logs.up.sql @@ -0,0 +1,24 @@ +CREATE TABLE `quota_flow_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `quota_flow_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '配额流水UUID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', + `change_target` TINYINT NOT NULL DEFAULT 0 COMMENT '调整额度类型 1=配额调整 2=使用', + `change_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更类型 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收', + `change_num` BIGINT NOT NULL DEFAULT 0 COMMENT '变更数量(正数增加,负数减少)', + `old_value` BIGINT NOT NULL DEFAULT 0 COMMENT '变更前数值', + `new_value` BIGINT NOT NULL DEFAULT 0 COMMENT '变更后数值', + `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联订单号(购买/退款类);其余场景写流水UUID保证幂等', + `quota_type` TINYINT NOT NULL DEFAULT 0 COMMENT '关联业务类型 1=cos 2=AI 3=社区分享 4=衣柜分享', + `related_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联业务ID', + `reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '变更原因/备注', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_quota_flow_idem` (`order_no`, `quota_type`, `change_type`), + KEY `idx_quota_flow_user` (`user_id`), + KEY `idx_quota_flow_uuid` (`quota_flow_uuid`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '配额流水日志表'; diff --git a/pkg/migration/000007_create_goods.down.sql b/pkg/migration/000007_create_goods.down.sql new file mode 100644 index 0000000..63949b7 --- /dev/null +++ b/pkg/migration/000007_create_goods.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `goods`; diff --git a/pkg/migration/000007_create_goods.up.sql b/pkg/migration/000007_create_goods.up.sql new file mode 100644 index 0000000..cd5a070 --- /dev/null +++ b/pkg/migration/000007_create_goods.up.sql @@ -0,0 +1,28 @@ +CREATE TABLE `goods` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `goods_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商品编码', + `goods_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '商品名称', + `goods_type` TINYINT NOT NULL DEFAULT 0 COMMENT '商品类型 1=会员包 2=上传包 3=AI推荐包 4=衣柜分享包 5=搭配分享包', + `price` BIGINT NOT NULL DEFAULT 0 COMMENT '商品原价(分)', + `discount_price` BIGINT NOT NULL DEFAULT 0 COMMENT '商品售价(分)', + `member_days` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送会员天数', + `cos_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送总上传额度', + `ai_recommend_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送AI推荐额度', + `closet_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送衣柜分享额度', + `outfit_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送搭配分享额度', + `valid_days` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送额度有效期天数(加量包用;会员包为 0 表示跟随 VIP)', + `description` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '商品描述', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=上架 2=下架', + `product_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '虚拟支付道具ID(空表示未发布)', + `product_status` TINYINT NOT NULL DEFAULT 0 COMMENT '虚拟支付道具发布状态 0=未发布 1=发布中 2=已发布 3=发布失败', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_goods_code` (`goods_code`), + KEY `idx_goods_type_status` (`goods_type`, `status`), + KEY `idx_goods_product_status` (`product_status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '商品表'; diff --git a/pkg/migration/000008_create_orders.down.sql b/pkg/migration/000008_create_orders.down.sql new file mode 100644 index 0000000..ac33c78 --- /dev/null +++ b/pkg/migration/000008_create_orders.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `orders`; diff --git a/pkg/migration/000008_create_orders.up.sql b/pkg/migration/000008_create_orders.up.sql new file mode 100644 index 0000000..e667b69 --- /dev/null +++ b/pkg/migration/000008_create_orders.up.sql @@ -0,0 +1,37 @@ +CREATE TABLE `orders` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户订单号', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '下单用户ID', + `goods_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联商品ID', + `goods_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '商品名称快照', + `unit_price` BIGINT NOT NULL DEFAULT 0 COMMENT '商品单价(分)', + `total_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '订单总金额(分)', + `pay_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '实付金额(分)', + `pay_channel` TINYINT NOT NULL DEFAULT 0 COMMENT '支付渠道 1=微信支付', + `order_status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款', + `transaction_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信支付流水号', + `member_days` BIGINT NOT NULL DEFAULT 0 COMMENT '购买的会员天数(快照)', + `cos_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送上传额度(快照)', + `ai_recommend_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送AI推荐额度(快照)', + `closet_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送衣柜分享额度(快照)', + `outfit_share_quota` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送搭配分享额度(快照)', + `expire_time` BIGINT NOT NULL DEFAULT 0 COMMENT '支付过期时间', + `pay_time` BIGINT NOT NULL DEFAULT 0 COMMENT '支付完成时间', + `refund_time` BIGINT NOT NULL DEFAULT 0 COMMENT '退款时间', + `refund_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '退款金额(分)', + `remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '订单备注', + `callback_raw` TEXT COMMENT '支付回调原始报文', + `xpay_product_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '虚拟支付道具ID(快照,pay_channel=2 时使用)', + `xpay_platform` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '虚拟支付平台 android/ios/harmony/windows', + `xpay_env` TINYINT NOT NULL DEFAULT 0 COMMENT '虚拟支付环境 0=现网 1=沙箱', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_order_no` (`order_no`), + KEY `idx_orders_user_status` (`user_id`, `order_status`), + KEY `idx_orders_expire` (`order_status`, `expire_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '订单表'; diff --git a/pkg/migration/000009_create_refund_flow_logs.down.sql b/pkg/migration/000009_create_refund_flow_logs.down.sql new file mode 100644 index 0000000..a7a071d --- /dev/null +++ b/pkg/migration/000009_create_refund_flow_logs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `refund_flow_logs`; diff --git a/pkg/migration/000009_create_refund_flow_logs.up.sql b/pkg/migration/000009_create_refund_flow_logs.up.sql new file mode 100644 index 0000000..4f25674 --- /dev/null +++ b/pkg/migration/000009_create_refund_flow_logs.up.sql @@ -0,0 +1,25 @@ +CREATE TABLE `refund_flow_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `order_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联订单ID', + `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户订单号(冗余,方便查询)', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID(冗余,方便按用户查)', + `refund_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户退款单号', + `wechat_refund_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '微信退款单号', + `refund_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '本次退款金额(分)', + `refund_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '退款原因', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '退款状态 0=待处理 1=退款中 2=退款成功 3=退款失败', + `apply_time` BIGINT NOT NULL DEFAULT 0 COMMENT '申请时间', + `success_time` BIGINT NOT NULL DEFAULT 0 COMMENT '退款成功时间', + `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因', + `callback_raw` TEXT COMMENT '微信退款回调原始报文', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_refund_flow_no` (`refund_no`), + KEY `idx_refund_flow_order` (`order_id`), + KEY `idx_refund_flow_user` (`user_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '退款流水日志表'; diff --git a/pkg/migration/000010_create_vip_flow_logs.down.sql b/pkg/migration/000010_create_vip_flow_logs.down.sql new file mode 100644 index 0000000..3ab4ccb --- /dev/null +++ b/pkg/migration/000010_create_vip_flow_logs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `vip_flow_logs`; diff --git a/pkg/migration/000010_create_vip_flow_logs.up.sql b/pkg/migration/000010_create_vip_flow_logs.up.sql new file mode 100644 index 0000000..7a974e4 --- /dev/null +++ b/pkg/migration/000010_create_vip_flow_logs.up.sql @@ -0,0 +1,21 @@ +CREATE TABLE `vip_flow_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', + `change_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更类型 1=开通 2=续费 3=活动赠送 4=到期失效 5=人工调整 6=退款回收', + `old_vip_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更前VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员', + `new_vip_type` TINYINT NOT NULL DEFAULT 0 COMMENT '变更后VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员', + `old_vip_expire` BIGINT NOT NULL DEFAULT 0 COMMENT '变更前到期时间(秒)', + `new_vip_expire` BIGINT NOT NULL DEFAULT 0 COMMENT '变更后到期时间(秒)', + `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联订单号(购买类变更)', + `source` TINYINT NOT NULL DEFAULT 0 COMMENT '变更来源 1=个人支付 2=活动赠送 3=系统 4=人工调整', + `reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '变更原因/备注', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_vip_flow_idem` (`order_no`, `change_type`), + KEY `idx_vip_flow_user` (`user_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '会员变更流水表'; diff --git a/pkg/migration/000011_create_benefit_grant_tasks.down.sql b/pkg/migration/000011_create_benefit_grant_tasks.down.sql new file mode 100644 index 0000000..f1d084b --- /dev/null +++ b/pkg/migration/000011_create_benefit_grant_tasks.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `benefit_grant_tasks`; diff --git a/pkg/migration/000011_create_benefit_grant_tasks.up.sql b/pkg/migration/000011_create_benefit_grant_tasks.up.sql new file mode 100644 index 0000000..4f04c02 --- /dev/null +++ b/pkg/migration/000011_create_benefit_grant_tasks.up.sql @@ -0,0 +1,21 @@ +CREATE TABLE `benefit_grant_tasks` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', + `order_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '商户订单号', + `goods_id` BIGINT NOT NULL DEFAULT 0 COMMENT '关联商品ID', + `benefit_type` TINYINT NOT NULL DEFAULT 0 COMMENT '权益类型 1=vip 2=cos 3=ai 4=closet_share 5=outfit_share', + `amount` BIGINT NOT NULL DEFAULT 0 COMMENT '发放数量', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 0=待发放 1=已发放 2=发放失败', + `retry_count` INT NOT NULL DEFAULT 0 COMMENT '已重试次数', + `max_retry` INT NOT NULL DEFAULT 0 COMMENT '最大重试次数', + `last_error` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '最近一次失败原因', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_benefit_task_idem` (`order_no`, `benefit_type`), + KEY `idx_benefit_task_status` (`status`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '权益发放补偿任务表'; diff --git a/pkg/migration/000012_create_ai_flow_logs.down.sql b/pkg/migration/000012_create_ai_flow_logs.down.sql new file mode 100644 index 0000000..aae5145 --- /dev/null +++ b/pkg/migration/000012_create_ai_flow_logs.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS `ai_horoscope_flow_logs`; +DROP TABLE IF EXISTS `ai_recommend_flow_logs`; diff --git a/pkg/migration/000012_create_ai_flow_logs.up.sql b/pkg/migration/000012_create_ai_flow_logs.up.sql new file mode 100644 index 0000000..88f2fb6 --- /dev/null +++ b/pkg/migration/000012_create_ai_flow_logs.up.sql @@ -0,0 +1,49 @@ +CREATE TABLE `ai_recommend_flow_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求唯一ID', + `server_request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '服务请求唯一ID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', + `model_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '大模型版本', + `prompt_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输入token数', + `completion_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输出token数', + `result_content` TEXT COMMENT '推荐结果结构化数据', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=成功 2=失败', + `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因', + `request_time` BIGINT NOT NULL DEFAULT 0 COMMENT '请求发起时间', + `response_time` BIGINT NOT NULL DEFAULT 0 COMMENT '响应返回时间', + PRIMARY KEY (`id`), + KEY `idx_ai_recommend_request` (`request_id`), + KEY `idx_ai_recommend_user` (`user_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'AI穿搭推荐流水表'; + +CREATE TABLE `ai_horoscope_flow_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '请求唯一ID(幂等依据)', + `zodiac` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '星座', + `server_request_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '服务请求唯一ID', + `stat_date` BIGINT NOT NULL DEFAULT 0 COMMENT '运势日期(YYYYMMDD)', + `model_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '大模型版本', + `prompt_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输入token数', + `completion_tokens` BIGINT NOT NULL DEFAULT 0 COMMENT '输出token数', + `result_content` TEXT COMMENT '生成结果结构化数据', + `horoscope_id` BIGINT NOT NULL DEFAULT 0 COMMENT '生成的运势记录ID', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=成功 2=失败', + `fail_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因', + `request_time` BIGINT NOT NULL DEFAULT 0 COMMENT '请求发起时间', + `response_time` BIGINT NOT NULL DEFAULT 0 COMMENT '响应返回时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ai_horoscope_request` (`request_id`), + KEY `idx_ai_horoscope_date_zodiac` (`stat_date`, `zodiac`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'AI运势生成流水表'; diff --git a/pkg/migration/000013_create_interactions.down.sql b/pkg/migration/000013_create_interactions.down.sql new file mode 100644 index 0000000..7f0a773 --- /dev/null +++ b/pkg/migration/000013_create_interactions.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS `likes`; +DROP TABLE IF EXISTS `collects`; diff --git a/pkg/migration/000013_create_interactions.up.sql b/pkg/migration/000013_create_interactions.up.sql new file mode 100644 index 0000000..75296fb --- /dev/null +++ b/pkg/migration/000013_create_interactions.up.sql @@ -0,0 +1,33 @@ +CREATE TABLE `collects` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `collect_type` TINYINT NOT NULL DEFAULT 0 COMMENT '业务类型 1=社区穿搭', + `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '业务目标ID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '收藏用户ID', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=已收藏 0=已取消', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_collect_user_target` (`collect_type`, `target_id`, `user_id`), + KEY `idx_collects_user` (`user_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '收藏表'; + +CREATE TABLE `likes` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `like_type` TINYINT NOT NULL DEFAULT 0 COMMENT '业务类型 1=社区穿搭', + `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '业务目标ID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '点赞用户ID', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 1=已点赞 0=已取消', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_like_user_target` (`like_type`, `target_id`, `user_id`), + KEY `idx_likes_user` (`user_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '点赞表'; diff --git a/pkg/migration/000014_create_user_notifications.down.sql b/pkg/migration/000014_create_user_notifications.down.sql new file mode 100644 index 0000000..4e6c0ae --- /dev/null +++ b/pkg/migration/000014_create_user_notifications.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `user_notifications`; diff --git a/pkg/migration/000014_create_user_notifications.up.sql b/pkg/migration/000014_create_user_notifications.up.sql new file mode 100644 index 0000000..3f7ac31 --- /dev/null +++ b/pkg/migration/000014_create_user_notifications.up.sql @@ -0,0 +1,20 @@ +CREATE TABLE `user_notifications` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `notification_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '消息UUID', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '接收用户ID', + `msg_type` TINYINT NOT NULL DEFAULT 0 COMMENT '消息类型 1=点赞通知 2=订单通知 3=系统通知', + `sender_id` BIGINT NOT NULL DEFAULT 0 COMMENT '发送者ID 系统消息为0', + `title` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '消息标题', + `content` TEXT COMMENT '消息内容', + `is_read` TINYINT NOT NULL DEFAULT 0 COMMENT '是否已读 1=未读 2=已读', + `read_time` BIGINT NOT NULL DEFAULT 0 COMMENT '阅读时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_notification_uuid` (`notification_uuid`), + KEY `idx_notifications_user_read` (`user_id`, `is_read`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户通知表'; diff --git a/pkg/migration/000015_create_horoscopes.down.sql b/pkg/migration/000015_create_horoscopes.down.sql new file mode 100644 index 0000000..2fccc8d --- /dev/null +++ b/pkg/migration/000015_create_horoscopes.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `horoscopes`; diff --git a/pkg/migration/000015_create_horoscopes.up.sql b/pkg/migration/000015_create_horoscopes.up.sql new file mode 100644 index 0000000..68e0849 --- /dev/null +++ b/pkg/migration/000015_create_horoscopes.up.sql @@ -0,0 +1,20 @@ +CREATE TABLE `horoscopes` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `stat_date` BIGINT NOT NULL DEFAULT 0 COMMENT '运势日期(YYYYMMDD)', + `zodiac` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '星座', + `overall_score` TINYINT NOT NULL DEFAULT 0 COMMENT '综合运势 1-5分', + `love_score` TINYINT NOT NULL DEFAULT 0 COMMENT '爱情运势 1-5分', + `career_score` TINYINT NOT NULL DEFAULT 0 COMMENT '工作运势 1-5分', + `wealth_score` TINYINT NOT NULL DEFAULT 0 COMMENT '财富运势 1-5分', + `daily_text` TEXT COMMENT '今日运势详情', + `lucky_color` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '幸运色', + `lucky_number` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '幸运数字', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_horoscope_date_zodiac` (`stat_date`, `zodiac`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '星座运势表'; diff --git a/pkg/migration/000016_create_audit_logs.down.sql b/pkg/migration/000016_create_audit_logs.down.sql new file mode 100644 index 0000000..3e9068a --- /dev/null +++ b/pkg/migration/000016_create_audit_logs.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS `outfit_audit_logs`; +DROP TABLE IF EXISTS `closet_audit_logs`; diff --git a/pkg/migration/000016_create_audit_logs.up.sql b/pkg/migration/000016_create_audit_logs.up.sql new file mode 100644 index 0000000..4e7eddc --- /dev/null +++ b/pkg/migration/000016_create_audit_logs.up.sql @@ -0,0 +1,31 @@ +CREATE TABLE `closet_audit_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '衣柜分享ID', + `audit_result` TINYINT NOT NULL DEFAULT 0 COMMENT '审核结果 1=通过 2=驳回', + `reject_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '审核原因', + `audit_time` BIGINT NOT NULL DEFAULT 0 COMMENT '审核时间', + PRIMARY KEY (`id`), + KEY `idx_closet_audit_target` (`target_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享审核日志表'; + +CREATE TABLE `outfit_audit_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `target_id` BIGINT NOT NULL DEFAULT 0 COMMENT '穿搭ID', + `audit_result` TINYINT NOT NULL DEFAULT 0 COMMENT '审核结果 1=通过 2=驳回', + `reject_reason` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '审核原因', + `audit_time` BIGINT NOT NULL DEFAULT 0 COMMENT '审核时间', + PRIMARY KEY (`id`), + KEY `idx_outfit_audit_target` (`target_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '穿搭审核日志表'; diff --git a/pkg/migration/000017_create_quota_grants.down.sql b/pkg/migration/000017_create_quota_grants.down.sql new file mode 100644 index 0000000..36842fb --- /dev/null +++ b/pkg/migration/000017_create_quota_grants.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `quota_grants`; diff --git a/pkg/migration/000017_create_quota_grants.up.sql b/pkg/migration/000017_create_quota_grants.up.sql new file mode 100644 index 0000000..07d544d --- /dev/null +++ b/pkg/migration/000017_create_quota_grants.up.sql @@ -0,0 +1,20 @@ +-- 额度凭证表:承载有有效期的赠送额度(会员包/加量包/广告激励)。 +CREATE TABLE `quota_grants` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户ID', + `quota_type` TINYINT NOT NULL DEFAULT 0 COMMENT '额度类型 1=cos上传 2=AI推荐 3=社区分享 4=衣柜分享', + `amount` BIGINT NOT NULL DEFAULT 0 COMMENT '赠送总量', + `used` BIGINT NOT NULL DEFAULT 0 COMMENT '已用量(Cos 永久额度扣减;AI/社区/衣柜 每日额度恒为 0)', + `source` TINYINT NOT NULL DEFAULT 0 COMMENT '来源 1=会员包 2=加量包 3=广告激励', + `expire_time` BIGINT NOT NULL DEFAULT 0 COMMENT '到期时间(秒,0 表示跟随 VIP 有效期)', + `ref_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '关联ID(订单号/广告记录ID)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_grant_ref_type` (`ref_id`, `quota_type`), + KEY `idx_grant_user_type_expire` (`user_id`, `quota_type`, `expire_time`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '额度凭证表(有有效期赠送额度)'; diff --git a/pkg/migration/000018_create_closet_share_receivers.down.sql b/pkg/migration/000018_create_closet_share_receivers.down.sql new file mode 100644 index 0000000..81339ec --- /dev/null +++ b/pkg/migration/000018_create_closet_share_receivers.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS `closet_share_receivers`; diff --git a/pkg/migration/000018_create_closet_share_receivers.up.sql b/pkg/migration/000018_create_closet_share_receivers.up.sql new file mode 100644 index 0000000..24aecad --- /dev/null +++ b/pkg/migration/000018_create_closet_share_receivers.up.sql @@ -0,0 +1,15 @@ +-- 衣柜分享接收人关联表:记录"谁收到了谁的分享"(访问即视为收到,一对多、幂等)。 +CREATE TABLE `closet_share_receivers` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间', + `updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '更新时间', + `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '删除时间(0 表示未删除)', + `created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人', + `updated_by` BIGINT NOT NULL DEFAULT 0 COMMENT '更新人', + `deleted_by` BIGINT NOT NULL DEFAULT 0 COMMENT '删除人', + `share_uuid` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '分享UUID', + `receiver_user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '接收人用户ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_closet_share_receiver` (`share_uuid`, `receiver_user_id`), + KEY `idx_closet_share_receivers_user` (`receiver_user_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '衣柜分享接收人关联表'; diff --git a/pkg/migration/migration.go b/pkg/migration/migration.go new file mode 100644 index 0000000..d8eb857 --- /dev/null +++ b/pkg/migration/migration.go @@ -0,0 +1,163 @@ +// Package migration 基于 golang-migrate 提供数据库结构迁移能力。 +// 迁移脚本以 go:embed 打包进二进制,通过独立命令 cmd/migrate 手动执行;服务启动流程不触发迁移。 +package migration + +import ( + "database/sql" + "embed" + "errors" + "fmt" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting" + _ "github.com/go-sql-driver/mysql" + "github.com/golang-migrate/migrate/v4" + migratemysql "github.com/golang-migrate/migrate/v4/database/mysql" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +// migrationsDir 迁移脚本所在目录:脚本与 migration.go 同级,故为当前目录。 +const migrationsDir = "." + +//go:embed *.sql +var migrationFS embed.FS + +// dsn 拼装迁移专用 DSN。 +// 必须开启 multiStatements:单个迁移文件内含多条 CREATE TABLE / CREATE INDEX 语句, +// 未开启时 MySQL 会拒绝执行。该参数与 gorm 使用的 DSN 不同,故此处单独拼装。 +func dsn(s *setting.MysqlSettingS) string { + return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&multiStatements=true", + s.UserName, + s.Password, + s.Host, + s.Port, + s.DBName, + ) +} + +// newMigrate 创建 migrate 实例;返回的 close 用于释放底层数据库连接。 +func newMigrate(s *setting.MysqlSettingS) (*migrate.Migrate, func() error, error) { + if s == nil { + return nil, nil, errors.New("mysql setting is nil") + } + if s.DBName == "" { + return nil, nil, errors.New("mysql DBName is empty") + } + + source, err := iofs.New(migrationFS, migrationsDir) + if err != nil { + return nil, nil, fmt.Errorf("load embedded migrations: %w", err) + } + + db, err := sql.Open("mysql", dsn(s)) + if err != nil { + return nil, nil, fmt.Errorf("open mysql: %w", err) + } + closeFn := func() error { return db.Close() } + + driver, err := migratemysql.WithInstance(db, &migratemysql.Config{}) + if err != nil { + _ = closeFn() + return nil, nil, fmt.Errorf("create mysql migrate driver: %w", err) + } + + m, err := migrate.NewWithInstance("iofs", source, "mysql", driver) + if err != nil { + _ = closeFn() + return nil, nil, fmt.Errorf("create migrate instance: %w", err) + } + return m, closeFn, nil +} + +// Up 执行到最新版本;无待执行迁移(ErrNoChange)视为成功。 +func Up(s *setting.MysqlSettingS) error { + m, closeFn, err := newMigrate(s) + if err != nil { + return err + } + defer closeFn() + + if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("migrate up: %w", err) + } + return nil +} + +// Down 全量回滚所有已应用的迁移。 +func Down(s *setting.MysqlSettingS) error { + m, closeFn, err := newMigrate(s) + if err != nil { + return err + } + defer closeFn() + + if err := m.Down(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("migrate down: %w", err) + } + return nil +} + +// Steps 按步数迁移:n > 0 向上 n 步,n < 0 向下 n 步。 +func Steps(s *setting.MysqlSettingS, n int) error { + m, closeFn, err := newMigrate(s) + if err != nil { + return err + } + defer closeFn() + + if err := m.Steps(n); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("migrate steps %d: %w", n, err) + } + return nil +} + +// Version 返回当前迁移版本号、是否为脏库状态。未执行过任何迁移时返回 0。 +func Version(s *setting.MysqlSettingS) (uint, bool, error) { + m, closeFn, err := newMigrate(s) + if err != nil { + return 0, false, err + } + defer closeFn() + + version, dirty, err := m.Version() + if err != nil { + if errors.Is(err, migrate.ErrNilVersion) { + return 0, false, nil + } + return 0, false, fmt.Errorf("migrate version: %w", err) + } + return version, dirty, nil +} + +// Force 强制设置版本号并清除脏标记,用于修复迁移中途失败导致的 dirty 状态。 +func Force(s *setting.MysqlSettingS, version int) error { + if version < 0 { + return fmt.Errorf("force version must not be negative: %d", version) + } + m, closeFn, err := newMigrate(s) + if err != nil { + return err + } + defer closeFn() + + if err := m.Force(version); err != nil { + return fmt.Errorf("migrate force %d: %w", version, err) + } + return nil +} + +// Run 按 action 执行迁移动作;arg 仅 steps / force 使用。 +// action 取值:up、down、steps、force。 +func Run(s *setting.MysqlSettingS, action string, arg int) error { + switch action { + case "up": + return Up(s) + case "down": + return Down(s) + case "steps": + return Steps(s, arg) + case "force": + return Force(s, arg) + default: + return fmt.Errorf("unknown migration action: %s", action) + } +} diff --git a/pkg/setting/section.go b/pkg/setting/section.go new file mode 100644 index 0000000..b647f7a --- /dev/null +++ b/pkg/setting/section.go @@ -0,0 +1,157 @@ +package setting + +// ServerSettingS 服务配置。时间字段单位为秒(int),避免 time.Duration 对整数配置按纳秒解析的歧义。 +type ServerSettingS struct { + Domain string // 服务域名(用于 CORS 允许来源,如 test.com) + RunMode string + HttpPort string + ReadTimeout int // 秒 + WriteTimeout int // 秒 +} + +type LogSettingS struct { + LogSavePath string + LogFileName string + LogFileExt string + MaxSize int + MaxBackups int + MaxAge int + LogLevel string +} + +type MysqlSettingS struct { + UserName string + Password string + Host string + Port int + DBName string + MaxIdleConns int + MaxOpenConns int + ConnMaxLifeTime int + ConnMaxIdleTime int +} + +type RedisSettingS struct { + Host string + Port int + Password string + DB int + PoolSize int + MaxIdleConns int + MinIdleConns int + MaxRetryBackoff int + MinRetryBackoff int + MaxRetries int + DialTimeout int + ReadTimeout int + WriteTimeout int + PoolTimeout int +} + +type OtelSettingS struct { + Enabled bool + ServiceName string + ServiceVersion string + Environment string + ExporterEndpoint string + Insecure bool + SamplerRatio float64 +} + +type JWTSettingS struct { + SecretKey string + Issuer string + AccessTokenExpire int // 秒 + RefreshTokenExpire int // 秒 +} + +type UploadSettingS struct { + TmpSavePath string + MaxSizeMB int + AllowedExtensions []string +} + +type CosSettingS struct { + CosSecretID string + CosSecretKey string + CosUploadDomain string + CosDownloadDomain string + CdnDownloadSecretKey string +} + +type TokenhubSettingS struct { + APIKey string + Model string +} + +type MattingModelSettingS struct { + Host string + Port int + MaxRetry int +} + +type ClassificationModelSettingS struct { + Host string + Port int + MaxRetry int +} + +// WechatMiniSettingS 微信小程序配置(用于 code2session 登录)。 +type WechatMiniSettingS struct { + AppID string + AppSecret string +} + +// WechatPaySettingS 微信支付 V3 商户配置。 +type WechatPaySettingS struct { + Enable bool // 是否启用微信支付;未开通时置 false,支付下单返回「未配置」 + AppID string // 小程序 AppID(下单/调起支付使用) + MchID string // 商户号 + APIv3Key string // APIv3 密钥(32 字节,用于回调解密) + SerialNo string // 商户证书序列号 + PrivateKeyPath string // 商户 API 私钥 PEM 文件路径 + NotifyURL string // 支付结果回调地址 + RefundNotifyURL string // 退款结果回调地址 +} + +// VirtualPaySettingS 微信小程序虚拟支付(XPay)配置。 +// 虚拟支付与普通微信支付为独立商户号、独立结算,配置项在 MP 后台「虚拟支付 → 基本配置」获取。 +type VirtualPaySettingS struct { + OfferID string // 虚拟支付 OfferID(AppID) + EnvAppKey string // 现网 AppKey(env=0) + SandboxAppKey string // 沙箱 AppKey(env=1,iOS 不支持沙箱,默认不启用) + Token string // 消息推送验签 Token(配置回调地址时自定义) + Enable bool // 是否启用虚拟支付;未开通时置 false,虚拟商品下单返回「虚拟支付未开通」 +} + +// UserDefaultQuotaSettingS 新用户注册时初始化的免费额度与赠送会员天数配置。 +// 使用指针类型以区分「未配置」与「显式配置为 0」: +// - nil:该项未配置(或整段缺失),由 service 层回落内置默认常量; +// - 非 nil:以配置值为准,允许为 0(额度类表示不赠送,GiftVIPDay 表示不赠送体验会员); +// - 负数:视为非法配置,回落内置默认常量。 +type UserDefaultQuotaSettingS struct { + Cos *int64 // 免费上传额度(永久,不每天重置) + AI *int64 // 免费 AI 推荐额度(每日,每天 0 点重置) + OutfitShare *int64 // 免费社区分享额度(每日,每天 0 点重置) + ClosetShare *int64 // 免费衣柜分享额度(每日,每天 0 点重置;0 表示不赠送) + GiftVIPDay *int64 // 注册赠送的体验会员天数(vip_type=4,0 表示不赠送) +} + +// AdRewardSettingS 广告激励额度配置。 +// 金额为服务端固定值,前端不传金额,从根源杜绝刷额度;各类型金额使用指针以区分 +// 「未配置/显式 0」,nil 或非正数表示不发放该类型激励。 +type AdRewardSettingS struct { + Enable bool // 是否开启广告激励;关闭时接口返回「广告激励未开启」 + Cos *int64 // 广告激励赠送的 Cos 上传额度(固定值) + AI *int64 // 广告激励赠送的 AI 推荐额度(固定值) + OutfitShare *int64 // 广告激励赠送的社区分享额度(固定值) + ClosetShare *int64 // 广告激励赠送的衣柜分享额度(固定值) +} + +func (s *Setting) ReadSection(k string, v any) error { + err := s.vp.UnmarshalKey(k, v) + if err != nil { + return err + } + return nil +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go new file mode 100644 index 0000000..42fdc86 --- /dev/null +++ b/pkg/setting/setting.go @@ -0,0 +1,24 @@ +package setting + +import ( + "github.com/spf13/viper" +) + +// Setting 封装 viper 配置读取能力。 +type Setting struct { + vp *viper.Viper +} + +// NewSetting 初始化配置读取器,仅从 configs/config.yaml 读取配置。 +// 所有配置项一律在配置文件中直接填写,不支持环境变量覆盖。 +func NewSetting() (*Setting, error) { + vp := viper.New() + vp.AddConfigPath("configs/") + vp.SetConfigName("config") + vp.SetConfigType("yaml") + if err := vp.ReadInConfig(); err != nil { + return nil, err + } + + return &Setting{vp: vp}, nil +} diff --git a/pkg/tokenhub/client.go b/pkg/tokenhub/client.go new file mode 100644 index 0000000..09eb23d --- /dev/null +++ b/pkg/tokenhub/client.go @@ -0,0 +1,101 @@ +// Package tokenhub 封装 Tokenhub 大模型 API 调用,用于生成每日星座运势与穿搭推荐。 +package tokenhub + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" +) + +const baseURL = "https://tokenhub.tencentmaas.com/v1/chat/completions" + +// Client Tokenhub API 客户端。 +type Client struct { + apiKey string + model string + httpClient *http.Client +} + +// NewClient 从全局配置创建客户端。 +func NewClient() *Client { + return &Client{ + apiKey: global.TokenhubSetting.APIKey, + model: global.TokenhubSetting.Model, + httpClient: &http.Client{ + Timeout: 120 * time.Second, + }, + } +} + +// Message 聊天消息。 +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// chatResponse Tokenhub chat/completions 外层响应结构(只取需要的字段)。 +type chatResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` +} + +// ResponseFormat JSON Schema 响应格式。 +type ResponseFormat struct { + Type string `json:"type"` + JSONSchema *JSONSchema `json:"json_schema"` +} + +// JSONSchema 结构化输出定义。 +type JSONSchema struct { + Name string `json:"name"` + Schema interface{} `json:"schema"` +} + +// ChatCompletion 调用混元 chat/completions,返回原始响应体。 +func (c *Client) ChatCompletion(ctx context.Context, messages []Message, responseFormat *ResponseFormat) ([]byte, error) { + body := map[string]interface{}{ + "model": c.model, + "messages": messages, + } + if responseFormat != nil { + body["response_format"] = responseFormat + } + + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + request, err := http.NewRequestWithContext(ctx, "POST", baseURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + request.Header.Set("Authorization", "Bearer "+c.apiKey) + request.Header.Set("Content-Type", "application/json") + + response, err := c.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("do request: %w", err) + } + defer response.Body.Close() + + data, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("tokenhub api error: status=%d, body=%s", response.StatusCode, string(data)) + } + + return data, nil +} diff --git a/pkg/tokenhub/fortune.go b/pkg/tokenhub/fortune.go new file mode 100644 index 0000000..95b2761 --- /dev/null +++ b/pkg/tokenhub/fortune.go @@ -0,0 +1,206 @@ +package tokenhub + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" +) + +// FortuneItem 单个星座运势。 +type FortuneItem struct { + Zodiac string `json:"zodiac"` + Overall int8 `json:"overall"` + Love int8 `json:"love"` + Work int8 `json:"work"` + Money int8 `json:"money"` + LuckyColor string `json:"lucky_color"` + LuckyNumber string `json:"lucky_number"` + Description string `json:"description"` +} + +// ToHoroscope 将 LLM 返回的运势项转换为 Horoscope 模型。 +func (f FortuneItem) ToHoroscope(statDate int64) *mysqlmodel.Horoscope { + return &mysqlmodel.Horoscope{ + StatDate: statDate, + Zodiac: f.Zodiac, + OverallScore: f.Overall, + LoveScore: f.Love, + CareerScore: f.Work, + WealthScore: f.Money, + LuckyColor: f.LuckyColor, + LuckyNumber: f.LuckyNumber, + DailyText: f.Description, + } +} + +// FortuneResponse Tokenhub 返回的运势 JSON。 +type FortuneResponse struct { + Date string `json:"date"` + Fortunes []FortuneItem `json:"fortunes"` +} + +// GenerateDailyFortunes 调用 Tokenhub 生成今日十二星座运势。 +func (c *Client) GenerateDailyFortunes(ctx context.Context) (*FortuneResponse, error) { + today := time.Now().Format("2006-01-02") + + messages := []Message{ + {Role: "user", Content: buildFortunePrompt(today)}, + } + + responseFormat := &ResponseFormat{ + Type: "json_schema", + JSONSchema: &JSONSchema{ + Name: "daily_fortunes", + Schema: buildFortuneSchema(), + }, + } + + data, err := c.ChatCompletion(ctx, messages, responseFormat) + if err != nil { + return nil, err + } + + // 解析混元外层响应 + var cr chatResponse + if err := json.Unmarshal(data, &cr); err != nil { + return nil, fmt.Errorf("unmarshal chat response: %w", err) + } + + if len(cr.Choices) == 0 { + return nil, fmt.Errorf("no choices in response") + } + + // 解析内层内容。注意:部分模型(如 hy3-preview)不严格遵守 json_schema, + // 可能返回中文键名的 map 结构,这里做容错解析。 + content := sanitizeJSONContent(cr.Choices[0].Message.Content) + result, err := parseFortuneContent(content) + if err != nil { + return nil, err + } + + // 兜底:模型未返回日期时,使用当天日期 + if result.Date == "" { + result.Date = time.Now().Format("2006-01-02") + } + + return result, nil +} + +// cnFortuneEntry 兼容模型返回的中文键名结构:{"白羊座":{"综合运势":4,...}}。 +type cnFortuneEntry struct { + Overall int8 `json:"综合运势"` + Love int8 `json:"爱情运势"` + Work int8 `json:"工作运势"` + Money int8 `json:"财富运势"` + LuckyColor string `json:"幸运色"` + LuckyNumber string `json:"幸运数字"` + Description string `json:"今日运势描述"` +} + +// sanitizeJSONContent 去除可能的 markdown 代码围栏与首尾空白。 +func sanitizeJSONContent(s string) string { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "```") { + if idx := strings.IndexByte(s, '\n'); idx != -1 { + s = s[idx+1:] + } + s = strings.TrimSuffix(strings.TrimSpace(s), "```") + } + return strings.TrimSpace(s) +} + +// parseFortuneContent 容错解析运势内容,兼容两种结构: +// 1. 标准结构 {"date":..,"fortunes":[{"zodiac":..,"overall":..}]} +// 2. 中文键名结构 {"白羊座":{"综合运势":..}} +func parseFortuneContent(content string) (*FortuneResponse, error) { + // 1. 标准结构 + var std FortuneResponse + if err := json.Unmarshal([]byte(content), &std); err == nil && len(std.Fortunes) > 0 { + return &std, nil + } + + // 2. 中文键名 map 结构 + var cn map[string]cnFortuneEntry + if err := json.Unmarshal([]byte(content), &cn); err == nil && len(cn) > 0 { + items := make([]FortuneItem, 0, len(cn)) + for zodiac, v := range cn { + // 过滤非星座键(无有效运势内容) + if v.Description == "" && v.Overall == 0 { + continue + } + items = append(items, FortuneItem{ + Zodiac: zodiac, + Overall: v.Overall, + Love: v.Love, + Work: v.Work, + Money: v.Money, + LuckyColor: v.LuckyColor, + LuckyNumber: v.LuckyNumber, + Description: v.Description, + }) + } + if len(items) > 0 { + return &FortuneResponse{Fortunes: items}, nil + } + } + + return nil, fmt.Errorf("no fortunes parsed from content, raw=%s", content) +} + +// buildFortunePrompt 构造运势生成 prompt。 +func buildFortunePrompt(today string) string { + return fmt.Sprintf(`请为十二星座生成%s的每日运势。 + +要求: +1. 十二星座运势要有差异,星数分布要合理,不能全部一样 +2. 描述要个性化,不要套模板,50 字以内,生动有趣,可结合当前季节和月份特点 +3. 整体运势要乐观积极 +4. 描述中不要出现星号(*)或 markdown 标记 +5. 每个星座给出幸运色(lucky_color)和幸运数字(lucky_number) + +必须严格按以下 JSON 结构输出(英文键名,overall/love/work/money 为 1-5 的整数),不要输出任何多余文字或代码围栏: +{ + "date": "%s", + "fortunes": [ + {"zodiac": "白羊座", "overall": 4, "love": 3, "work": 5, "money": 4, "lucky_color": "红色", "lucky_number": "6", "description": "……"} + ] +} +其中 fortunes 数组必须恰好包含 12 个星座(白羊座、金牛座、双子座、巨蟹座、狮子座、处女座、天秤座、天蝎座、射手座、摩羯座、水瓶座、双鱼座)。`, + today, today) +} + +// buildFortuneSchema 构造 JSON Schema 让混元按结构返回。 +func buildFortuneSchema() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "date": map[string]interface{}{ + "type": "string", + "description": "日期,格式 YYYY-MM-DD", + }, + "fortunes": map[string]interface{}{ + "type": "array", + "description": "十二星座运势列表,必须恰好 12 条", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "zodiac": map[string]interface{}{"type": "string", "description": "星座名称,如:白羊座"}, + "overall": map[string]interface{}{"type": "integer", "description": "综合运势,1-5 星"}, + "love": map[string]interface{}{"type": "integer", "description": "爱情运势,1-5 星"}, + "work": map[string]interface{}{"type": "integer", "description": "工作运势,1-5 星"}, + "money": map[string]interface{}{"type": "integer", "description": "财富运势,1-5 星"}, + "lucky_color": map[string]interface{}{"type": "string", "description": "幸运色"}, + "lucky_number": map[string]interface{}{"type": "string", "description": "幸运数字"}, + "description": map[string]interface{}{"type": "string", "description": "今日运势描述,50 字以内"}, + }, + "required": []string{"zodiac", "overall", "love", "work", "money", "lucky_color", "lucky_number", "description"}, + }, + }, + }, + "required": []string{"date", "fortunes"}, + } +} diff --git a/pkg/tokenhub/outfit_recommend.go b/pkg/tokenhub/outfit_recommend.go new file mode 100644 index 0000000..a36d19d --- /dev/null +++ b/pkg/tokenhub/outfit_recommend.go @@ -0,0 +1,262 @@ +package tokenhub + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel" +) + +// outfitCategories 穿搭推荐支持的衣物大类,与 mysqlmodel.Clothes.CategoryMain 取值对齐。 +var outfitCategories = []string{"上装", "下装", "连衣裙", "外套", "鞋", "配饰"} + +// RecommendLLMItem 大模型返回的单品推荐。 +type RecommendLLMItem struct { + Type string `json:"type"` // 品类:上装/下装/连衣裙/外套/鞋/配饰 + ClothesID int64 `json:"clothes_id"` // 衣橱衣物 ID + ClothesUUID string `json:"clothes_uuid"` // 衣橱衣物 UUID + Reason string `json:"reason"` // 选择理由 +} + +// RecommendLLMResponse 大模型返回的穿搭推荐。 +type RecommendLLMResponse struct { + OutfitName string `json:"outfit_name"` + Description string `json:"description"` + Style string `json:"style"` // 穿搭风格 + Scene string `json:"scene"` // 适合场景 + Items []RecommendLLMItem `json:"items"` +} + +// GenerateOutfitRecommendation 调用混元生成今日穿搭推荐。 +func (c *Client) GenerateOutfitRecommendation(ctx context.Context, zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes) (*RecommendLLMResponse, error) { + messages := []Message{ + {Role: "user", Content: buildRecommendPrompt(zodiac, fortune, clothes)}, + } + + responseFormat := &ResponseFormat{ + Type: "json_schema", + JSONSchema: &JSONSchema{ + Name: "outfit_recommendation", + Schema: buildRecommendSchema(), + }, + } + + data, err := c.ChatCompletion(ctx, messages, responseFormat) + if err != nil { + return nil, fmt.Errorf("call hunyuan for outfit recommend: %w", err) + } + + var cr chatResponse + if err := json.Unmarshal(data, &cr); err != nil { + return nil, fmt.Errorf("unmarshal chat response: %w", err) + } + + if len(cr.Choices) == 0 { + return nil, fmt.Errorf("no choices in response") + } + + var result RecommendLLMResponse + content := sanitizeJSONContent(cr.Choices[0].Message.Content) + if err := json.Unmarshal([]byte(content), &result); err != nil { + return nil, fmt.Errorf("unmarshal outfit recommend json: %w, raw=%s", err, content) + } + + return &result, nil +} + +// buildRecommendPrompt 构造穿搭推荐 prompt。 +func buildRecommendPrompt(zodiac string, fortune *FortuneItem, clothes []mysqlmodel.Clothes) string { + clothesText := FormatClothesInfo(clothes) + + return fmt.Sprintf(`你是一位专业的时尚搭配师。请根据用户今天的星座运势和衣橱中的衣物,为用户推荐一套今日穿搭。 + +## 用户信息 +- 星座:%s +- 今日综合运势:%d 星(满分 5 星) +- 今日爱情运势:%d 星 +- 今日工作运势:%d 星 +- 今日财富运势:%d 星 +- 今日运势描述:%s + +## 用户衣橱 +%s + +## 搭配规则 +1. 所有推荐单品必须严格选自用户衣橱,clothes_id 与 clothes_uuid 必须与衣橱中存在的衣物一一对应,不得虚构 +2. 先识别衣橱中包含的有效品类,仅统计以下 6 类:上装、下装、连衣裙、外套、鞋、配饰: + - 每个存在的品类最多推荐 1 件最合适的单品 + - 衣橱中没有的品类,不在结果中输出,无需强制凑满 6 件 + - 连衣裙为「一件式」单品,若选择连衣裙则通常不再搭配下装,可搭配外套、鞋、配饰 + - 鞋为必备品类(若衣橱中存在),配饰为可选点缀 +3. 结合今日运势调整风格:运势评分高可选择出彩、有设计感的搭配;运势一般则选择稳重、舒适的基础款搭配 +4. 参考衣服的版型(version)、面料材质(material)、图案纹理(pattern)、颜色(主色/副色)、袖型、衣长等进行搭配,确保搭配和谐 +5. 保证所选单品之间颜色、风格协调统一 +6. 为每件推荐单品撰写一句简洁的推荐理由 +7. 为整套穿搭拟定一个贴合风格与运势的好听标题 + +## 输出要求 +必须严格按以下 JSON 结构输出,不要输出任何多余文字、解释或代码围栏: +{ + "outfit_name": "穿搭标题", + "description": "结合今日运势的整体搭配说明", + "style": "穿搭风格,如:简约/复古/街头/甜美等", + "scene": "适合场景,如:通勤/约会/休闲/运动等", + "items": [ + {"type": "上装", "clothes_id": 1, "clothes_uuid": "衣橱中该衣物对应的UUID", "reason": "单品推荐理由"} + ] +} +补充约束: +- type 字段取值仅限「上装」「下装」「连衣裙」「外套」「鞋」「配饰」,与衣橱实际品类一一对应 +- style 表示穿搭风格(如简约/复古/街头/甜美),scene 表示适合场景(如通勤/约会/休闲/运动),两者含义不同,必须分别输出 +- items 数组长度由衣橱中存在的品类数量决定,不存在的品类不写入数组 +- clothes_id 必须为整数,且必须是衣橱中真实存在的 ID +- clothes_uuid 必须从「用户衣橱」信息中精确复制,且与 clothes_id 一一对应,严禁编造或改动 UUID`, + zodiac, fortune.Overall, fortune.Love, fortune.Work, fortune.Money, fortune.Description, clothesText) +} + +// buildRecommendSchema 构造 JSON Schema 要求大模型结构化输出。 +func buildRecommendSchema() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "outfit_name": map[string]interface{}{ + "type": "string", + "description": "穿搭标题", + }, + "description": map[string]interface{}{ + "type": "string", + "description": "穿搭整体描述,结合运势说明搭配理由", + }, + "style": map[string]interface{}{ + "type": "string", + "description": "穿搭风格,如:简约、复古、街头、甜美等", + }, + "scene": map[string]interface{}{ + "type": "string", + "description": "适合场景,如:通勤、约会、休闲、运动等", + }, + "items": map[string]interface{}{ + "type": "array", + "description": "推荐的单品列表,每个存在的品类最多一件,不存在的品类不输出", + "items": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "type": map[string]interface{}{ + "type": "string", + "description": "品类,必须是以下之一:上装、下装、连衣裙、外套、鞋、配饰", + "enum": outfitCategories, + }, + "clothes_id": map[string]interface{}{ + "type": "integer", + "description": "衣橱中衣物的 ID", + }, + "clothes_uuid": map[string]interface{}{ + "type": "string", + "description": "衣橱中衣物的 UUID,必须与 clothes_id 对应", + }, + "reason": map[string]interface{}{ + "type": "string", + "description": "推荐理由,一句话说明为什么选这件", + }, + }, + "required": []string{"type", "clothes_id", "clothes_uuid", "reason"}, + }, + }, + }, + "required": []string{"outfit_name", "description", "style", "scene", "items"}, + } +} + +// ValidateClothesIDs 校验并纠偏大模型返回的推荐结果: +// 1. 过滤 clothes_id 不在用户衣橱中的项; +// 2. 将 type 规范化为衣物实际的大类(CategoryMain); +// 3. 以衣橱实际数据回填正确的 ClothesUUID(防止模型抄错 UUID); +// 4. 对重复的 clothes_id 去重(保留首次出现)。 +func (r *RecommendLLMResponse) ValidateClothesIDs(clothes []mysqlmodel.Clothes) { + clothesByID := make(map[int64]mysqlmodel.Clothes, len(clothes)) + for _, c := range clothes { + clothesByID[c.ID] = c + } + + filtered := make([]RecommendLLMItem, 0, len(r.Items)) + seen := make(map[int64]struct{}, len(r.Items)) + for _, item := range r.Items { + c, ok := clothesByID[item.ClothesID] + if !ok { + continue + } + if _, dup := seen[item.ClothesID]; dup { + continue + } + // 以衣橱中实际数据为准,纠偏模型可能返回的 type 与 UUID。 + item.Type = c.CategoryMain + item.ClothesUUID = c.ClothesUUID + seen[item.ClothesID] = struct{}{} + filtered = append(filtered, item) + } + r.Items = filtered +} + +// GetClothesIDs 获取推荐中所有不重复的 clothes ID。 +func (r *RecommendLLMResponse) GetClothesIDs() []int64 { + ids := make([]int64, 0, len(r.Items)) + seen := make(map[int64]struct{}, len(r.Items)) + for _, item := range r.Items { + if _, ok := seen[item.ClothesID]; ok { + continue + } + seen[item.ClothesID] = struct{}{} + ids = append(ids, item.ClothesID) + } + return ids +} + +// FormatClothesInfo 将衣橱衣物列表转换为传给 LLM 的摘要格式。 +// 使用「字段名:值」标注,补全穿搭相关字段,便于模型准确理解每个属性。 +// 所有字符串字段经 sanitizePromptField 清洗,降低 prompt 注入风险。 +func FormatClothesInfo(clothesList []mysqlmodel.Clothes) string { + var sb strings.Builder + for _, c := range clothesList { + sb.WriteString(fmt.Sprintf( + "- ID:%d | UUID:%s | 大类:%s | 小类:%s | 名称:%s | 主色:%s | 副色:%s | 风格:%s | 版型:%s | 材质:%s | 花纹:%s | 厚度:%s | 袖型:%s | 衣长:%s | 领型:%s | 正式度:%s | 场合:%s | 视觉冲击力:%s\n", + c.ID, + sanitizePromptField(c.ClothesUUID), sanitizePromptField(c.CategoryMain), sanitizePromptField(c.CategorySub), + sanitizePromptField(c.Name), sanitizePromptField(c.ColorMain), sanitizePromptField(c.ColorSub), + sanitizePromptField(c.Style), sanitizePromptField(c.Version), sanitizePromptField(c.Material), + sanitizePromptField(c.Pattern), sanitizePromptField(c.Thickness), sanitizePromptField(c.SleeveType), + sanitizePromptField(c.ClothesType), sanitizePromptField(c.NecklineType), sanitizePromptField(c.Formality), + sanitizePromptField(c.Occasions), sanitizePromptField(c.VisualImpact))) + } + return sb.String() +} + +// sanitizePromptField 清洗拼入 prompt 的用户可控字段: +// 截断长度、移除换行与控制字符、压缩连续空白,降低 prompt 注入风险。 +func sanitizePromptField(s string) string { + const maxLen = 200 + if s == "" { + return "" + } + + var b strings.Builder + for _, r := range s { + switch { + case r == '\n' || r == '\r' || r == '\t': + b.WriteRune(' ') // 换行/制表符压缩为空格,防止注入换行指令。 + case r < 0x20 || r == 0x7F: + continue // 丢弃其它控制字符。 + default: + b.WriteRune(r) + } + } + + // 压缩连续空白。 + out := strings.Join(strings.Fields(b.String()), " ") + runes := []rune(out) + if len(runes) > maxLen { + out = string(runes[:maxLen]) + } + return out +} diff --git a/pkg/tokenhub/scheduler.go b/pkg/tokenhub/scheduler.go new file mode 100644 index 0000000..ab75042 --- /dev/null +++ b/pkg/tokenhub/scheduler.go @@ -0,0 +1,170 @@ +package tokenhub + +import ( + "context" + "log/slog" + "strconv" + "sync" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/redisdao" + "github.com/google/uuid" +) + +// fortuneEnsureMu 用于同一实例内串行化当日运势兜底生成,避免并发重复触发 LLM。 +var fortuneEnsureMu sync.Mutex + +// StartFortuneScheduler 启动后台定时任务:每天 0:00 生成当日运势并入库。 +func StartFortuneScheduler() { + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("fortune scheduler panic", "recover", r) + } + }() + slog.Info("fortune scheduler started") + + client := NewClient() + horoscopeDao := mysqldao.NewHoroscopeDao() + + for { + // 每次循环重新计算到下一个午夜的等待时间,避免漂移累积。 + now := time.Now() + next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) + wait := next.Sub(now) + + slog.Info("fortune scheduler: next run", + "at", next.Format("2006-01-02 15:04:05"), + "in", wait.Round(time.Second).String(), + ) + + time.Sleep(wait) + + // 生成 & 入库(带分布式锁,多实例仅一个执行)。 + func() { + defer func() { + if r := recover(); r != nil { + slog.Error("fortune generation panic", "recover", r) + } + }() + runFortuneGenerationWithLock(client, horoscopeDao) + }() + } + }() +} + +// runFortuneGeneration 调用混元生成运势,写入数据库。 +func runFortuneGeneration(client *Client, horoscopeDao *mysqldao.HoroscopeDao) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + ctx, span := global.Tracer.Start(ctx, "scheduler.FortuneGeneration") + defer span.End() + + slog.InfoContext(ctx, "generating daily fortunes...") + + result, err := client.GenerateDailyFortunes(ctx) + if err != nil { + span.RecordError(err) + slog.ErrorContext(ctx, "generate fortunes failed", "error", err) + return + } + + // result.Date 形如 "2006-01-02",转换为 Horoscope.StatDate 的 YYYYMMDD 整数。 + date, err := time.Parse("2006-01-02", result.Date) + if err != nil { + slog.ErrorContext(ctx, "parse fortune date failed", "date", result.Date, "error", err) + return + } + statDate, err := strconv.ParseInt(date.Format("20060102"), 10, 64) + if err != nil { + slog.ErrorContext(ctx, "parse stat date failed", "date", result.Date, "error", err) + return + } + + successCount := 0 + for _, f := range result.Fortunes { + horoscope := f.ToHoroscope(statDate) + if err := horoscopeDao.HoroscopeCreate(ctx, horoscope); err != nil { + slog.ErrorContext(ctx, "save horoscope failed", "zodiac", f.Zodiac, "error", err) + } else { + successCount++ + } + } + + slog.InfoContext(ctx, "daily fortune generation completed", + "total", len(result.Fortunes), + "saved", successCount, + ) +} + +// runFortuneGenerationWithLock 带 Redis 分布式锁执行当日运势生成: +// 多实例部署时仅一个实例执行;锁释放校验持有者 value,防止超时误删其它实例的锁。 +func runFortuneGenerationWithLock(client *Client, horoscopeDao *mysqldao.HoroscopeDao) { + today := time.Now().Format("2006-01-02") + lockKey := "ztcloset:lock:fortune:daily:" + today + lockVal := uuid.NewString() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + ok, lerr := redisdao.TryLock(ctx, lockKey, lockVal, 5*time.Minute) + if lerr != nil || !ok { + slog.Info("fortune generation already in progress, skip", "date", today) + return + } + defer redisdao.Unlock(context.Background(), lockKey, lockVal) + + runFortuneGeneration(client, horoscopeDao) +} + +// EnsureTodayFortunes 检查今日运势数据是否完整,不完整则自动生成。 +// 该函数异步执行,适用于服务器启动时兜底。 +func EnsureTodayFortunes() { + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("EnsureTodayFortunes panic", "recover", r) + } + }() + // 进程内防重:已有兜底生成在进行中则直接跳过。 + if !fortuneEnsureMu.TryLock() { + return + } + defer fortuneEnsureMu.Unlock() + + client := NewClient() + horoscopeDao := mysqldao.NewHoroscopeDao() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + now := time.Now() + today := now.Format("2006-01-02") + statDate, err := strconv.ParseInt(now.Format("20060102"), 10, 64) + if err != nil { + slog.Error("EnsureTodayFortunes: parse stat date failed", "error", err) + return + } + + horoscopes, err := horoscopeDao.HoroscopeGetByDate(ctx, statDate) + if err != nil { + slog.Warn("EnsureTodayFortunes: query today fortunes failed, will try to generate", "error", err, "date", today) + } + + if len(horoscopes) >= 12 { + slog.Info("EnsureTodayFortunes: today fortunes already complete, skip", "date", today, "count", len(horoscopes)) + return + } + + slog.Info("EnsureTodayFortunes: today fortunes incomplete, generating...", + "date", today, + "existing", len(horoscopes), + "needed", 12, + ) + + runFortuneGenerationWithLock(client, horoscopeDao) + }() +} diff --git a/pkg/tracing/fanout.go b/pkg/tracing/fanout.go new file mode 100644 index 0000000..dd51e11 --- /dev/null +++ b/pkg/tracing/fanout.go @@ -0,0 +1,68 @@ +package tracing + +import ( + "context" + "errors" + "log/slog" +) + +// FanoutHandler 是一个将日志记录扇出到多个 slog.Handler 的组合处理器, +// 用于同时输出本地 JSON 日志与 OTel 日志桥接。 +type FanoutHandler struct { + handlers []slog.Handler +} + +// NewFanoutHandler 创建扇出处理器(忽略 nil handler)。 +func NewFanoutHandler(handlers ...slog.Handler) *FanoutHandler { + filtered := make([]slog.Handler, 0, len(handlers)) + for _, h := range handlers { + if h != nil { + filtered = append(filtered, h) + } + } + return &FanoutHandler{handlers: filtered} +} + +// Enabled 任一子处理器启用即返回 true。 +func (f *FanoutHandler) Enabled(ctx context.Context, level slog.Level) bool { + for _, h := range f.handlers { + if h.Enabled(ctx, level) { + return true + } + } + return false +} + +// Handle 将记录分发给每个启用的子处理器(各自使用副本,避免相互污染)。 +func (f *FanoutHandler) Handle(ctx context.Context, r slog.Record) error { + var errs []error + for _, h := range f.handlers { + if h.Enabled(ctx, r.Level) { + if err := h.Handle(ctx, r.Clone()); err != nil { + errs = append(errs, err) + } + } + } + return errors.Join(errs...) +} + +// WithAttrs 为每个子处理器附加属性。 +func (f *FanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + next := make([]slog.Handler, len(f.handlers)) + for i, h := range f.handlers { + next[i] = h.WithAttrs(attrs) + } + return &FanoutHandler{handlers: next} +} + +// WithGroup 为每个子处理器开启分组。 +func (f *FanoutHandler) WithGroup(name string) slog.Handler { + if name == "" { + return f + } + next := make([]slog.Handler, len(f.handlers)) + for i, h := range f.handlers { + next[i] = h.WithGroup(name) + } + return &FanoutHandler{handlers: next} +} diff --git a/pkg/tracing/otel.go b/pkg/tracing/otel.go new file mode 100644 index 0000000..c1031c8 --- /dev/null +++ b/pkg/tracing/otel.go @@ -0,0 +1,35 @@ +package tracing + +import ( + "context" + "log/slog" + + "go.opentelemetry.io/otel/trace" +) + +type OtelContextHandler struct { + Handler slog.Handler +} + +func (h OtelContextHandler) Handle(ctx context.Context, r slog.Record) error { + if span := trace.SpanFromContext(ctx); span.SpanContext().IsValid() { + sc := span.SpanContext() + r.AddAttrs( + slog.String("trace_id", sc.TraceID().String()), + slog.String("span_id", sc.SpanID().String()), + ) + } + return h.Handler.Handle(ctx, r) +} + +func (h OtelContextHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.Handler.Enabled(ctx, level) +} + +func (h OtelContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return OtelContextHandler{Handler: h.Handler.WithAttrs(attrs)} +} + +func (h OtelContextHandler) WithGroup(name string) slog.Handler { + return OtelContextHandler{Handler: h.Handler.WithGroup(name)} +} diff --git a/pkg/tracing/provider.go b/pkg/tracing/provider.go new file mode 100644 index 0000000..7e67164 --- /dev/null +++ b/pkg/tracing/provider.go @@ -0,0 +1,154 @@ +package tracing + +import ( + "context" + "errors" + "fmt" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting" + + "go.opentelemetry.io/contrib/instrumentation/runtime" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + logglobal "go.opentelemetry.io/otel/log/global" + metricnoop "go.opentelemetry.io/otel/metric/noop" + "go.opentelemetry.io/otel/propagation" + sdklog "go.opentelemetry.io/otel/sdk/log" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +// defaultServiceName 缺省服务名。 +const defaultServiceName = "zt-closet-backend" + +// noopShutdown 安全的空关闭函数。 +func noopShutdown(context.Context) error { return nil } + +// NewOTelProvider 初始化 OpenTelemetry 三大信号(Traces/Metrics/Logs)并通过 OTLP gRPC 导出。 +// +// 返回: +// - trace.Tracer:全局 Tracer(供 global.Tracer 使用); +// - func(ctx) error:聚合的优雅关闭函数,进程退出时调用以 flush 三个 Provider; +// - error:初始化错误。 +// +// 行为约定: +// - cfg.Enabled=false:返回 no-op Tracer 与空 Shutdown,不建立任何导出器(业务层 Tracer.Start 仍安全)。 +// - cfg.Insecure=true:OTLP gRPC 使用非 TLS(内网采集器)。 +// - 导出器采用惰性连接,采集器不可用时不阻断启动,仅后台重试。 +func NewOTelProvider(cfg *setting.OtelSettingS) (trace.Tracer, func(context.Context) error, error) { + name := defaultServiceName + if cfg != nil && cfg.ServiceName != "" { + name = cfg.ServiceName + } + + // 未启用:降级为 no-op。 + if cfg == nil || !cfg.Enabled { + return noop.NewTracerProvider().Tracer(name), noopShutdown, nil + } + + ctx := context.Background() + + res, err := resource.New(ctx, resource.WithAttributes( + attribute.String("service.name", name), + attribute.String("service.version", valueOr(cfg.ServiceVersion, "unknown")), + attribute.String("deployment.environment", valueOr(cfg.Environment, "unknown")), + )) + if err != nil { + return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel build resource: %w", err) + } + + endpoint := cfg.ExporterEndpoint + + // ===== Traces ===== + traceOpts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(endpoint)} + if cfg.Insecure { + traceOpts = append(traceOpts, otlptracegrpc.WithInsecure()) + } + traceExp, err := otlptracegrpc.New(ctx, traceOpts...) + if err != nil { + return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel trace exporter: %w", err) + } + + ratio := cfg.SamplerRatio + if ratio <= 0 { + ratio = 1.0 + } + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(traceExp), + sdktrace.WithResource(res), + sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))), + ) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + // ===== Metrics ===== + metricOpts := []otlpmetricgrpc.Option{otlpmetricgrpc.WithEndpoint(endpoint)} + if cfg.Insecure { + metricOpts = append(metricOpts, otlpmetricgrpc.WithInsecure()) + } + metricExp, err := otlpmetricgrpc.New(ctx, metricOpts...) + if err != nil { + // 已建 trace,尽力关闭并恢复全局默认 provider,避免留下已关闭的 provider。 + _ = tp.Shutdown(ctx) + otel.SetTracerProvider(noop.NewTracerProvider()) + return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel metric exporter: %w", err) + } + mp := sdkmetric.NewMeterProvider( + sdkmetric.WithResource(res), + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)), + ) + otel.SetMeterProvider(mp) + + // Go runtime 指标采集(GC、goroutine、内存等)。 + if rerr := runtime.Start(runtime.WithMeterProvider(mp)); rerr != nil { + // 非致命,仅记录。 + fmt.Printf("otel runtime metrics start failed: %v\n", rerr) + } + + // ===== Logs ===== + logOpts := []otlploggrpc.Option{otlploggrpc.WithEndpoint(endpoint)} + if cfg.Insecure { + logOpts = append(logOpts, otlploggrpc.WithInsecure()) + } + logExp, err := otlploggrpc.New(ctx, logOpts...) + if err != nil { + _ = tp.Shutdown(ctx) + _ = mp.Shutdown(ctx) + otel.SetTracerProvider(noop.NewTracerProvider()) + otel.SetMeterProvider(metricnoop.NewMeterProvider()) + return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel log exporter: %w", err) + } + lp := sdklog.NewLoggerProvider( + sdklog.WithResource(res), + sdklog.WithProcessor(sdklog.NewBatchProcessor(logExp)), + ) + logglobal.SetLoggerProvider(lp) + + // 聚合优雅关闭:依次 flush/关闭三个 Provider。 + shutdown := func(ctx context.Context) error { + return errors.Join( + tp.Shutdown(ctx), + mp.Shutdown(ctx), + lp.Shutdown(ctx), + ) + } + + return tp.Tracer(name), shutdown, nil +} + +// valueOr 返回 v,v 为空时返回 def。 +func valueOr(v, def string) string { + if v == "" { + return def + } + return v +} diff --git a/pkg/upload/upload.go b/pkg/upload/upload.go new file mode 100644 index 0000000..b3341a0 --- /dev/null +++ b/pkg/upload/upload.go @@ -0,0 +1,217 @@ +// Package upload 文件上传、校验、落盘、公开 token 生成的工具函数。 +package upload + +import ( + "context" + "crypto/md5" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "mime/multipart" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" +) + +func FileGetExt(name string) string { + return strings.ToLower(path.Ext(name)) +} + +// 校验扩展名是否在白名单内。 +func FileCheckAllowExt(ext string, allowed []string) bool { + ext = strings.TrimPrefix(strings.ToLower(ext), ".") + for _, e := range allowed { + if strings.ToLower(e) == ext { + return true + } + } + return false +} + +// 校验文件大小。maxMB <= 0 表示不限制。 +func FileCheckMaxSize(size, maxMB int64) bool { + if maxMB <= 0 { + return true + } + return size <= maxMB*1024*1024 +} + +// FileCheckImageMagic 校验文件头 magic bytes,判断内容是否为真实图片(jpeg/png/gif/webp)。 +// 用于防止伪造扩展名上传非图片文件(如脚本、可执行文件、HTML)。 +func FileCheckImageMagic(data []byte) bool { + if len(data) < 4 { + return false + } + + // JPEG: FF D8 FF + if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { + return true + } + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if len(data) >= 8 && + data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 && + data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A { + return true + } + + // GIF: "GIF8" + if data[0] == 'G' && data[1] == 'I' && data[2] == 'F' && data[3] == '8' { + return true + } + + // WebP: "RIFF" .... "WEBP" + if len(data) >= 12 && + data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' && + data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' { + return true + } + + return false +} + +// GenFileName 生成防冲突随机相对文件名。 +func FileGenName(originName string) (string, error) { + ext := FileGetExt(originName) + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b) + ext, nil +} + +// 临时保存从前端获取的图片 +func FileSaveTmp(ctx context.Context, file *multipart.FileHeader, fileName string, userID int64) (string, error) { + ctx, span := global.Tracer.Start(ctx, "upload.FileSaveTmp") + defer span.End() + + dir := filepath.Join(global.UploadSetting.TmpSavePath, fmt.Sprint(userID)) + if err := os.MkdirAll(dir, 0o755); err != nil { + span.RecordError(err) + return "", err + } + + tmpFile := filepath.Join(dir, fileName) + + dst, err := os.Create(tmpFile) + if err != nil { + return "", err + } + defer dst.Close() + + src, err := file.Open() + if err != nil { + return "", err + } + defer src.Close() + + if _, err := io.Copy(dst, src); err != nil { + return "", err + } + + return tmpFile, nil + +} + +// 从后端抠图返回的图片保存到临时文件(subDir 为相对子目录,如 "用户UUID/用户ID")。 +func FileSaveTmpBytes(ctx context.Context, data []byte, fileName string, subDir string) (string, error) { + _, span := global.Tracer.Start(ctx, "upload.FileSaveTmpBytes") + defer span.End() + + dir := filepath.Join(global.UploadSetting.TmpSavePath, subDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + span.RecordError(err) + return "", err + } + + tmpFile := filepath.Join(dir, fileName) + if err := os.WriteFile(tmpFile, data, 0o644); err != nil { + span.RecordError(err) + return "", err + } + return tmpFile, nil +} + +// 将抠图后的文件上传到cos +func FileUploadCos(ctx context.Context, fileName string) error { + ctx, span := global.Tracer.Start(ctx, "upload.FileUploadCos") + defer span.End() + + // COS 客户端初始化失败时降级为 nil,此处做保护避免 nil 指针 panic。 + if global.CosClient == nil { + err := fmt.Errorf("cos client not initialized") + span.RecordError(err) + return err + } + + _, _, err := global.CosClient.Object.Upload(ctx, fileName, fmt.Sprintf("%s/%s", global.UploadSetting.TmpSavePath, fileName), nil) + if err != nil { + span.RecordError(err) + return err + } + + return nil + +} + +// 删除 COS 对象。key 为对象键(即 processed_image 存储值)。 +// key 为空直接返回 nil;client 未初始化返回 error。 +// 删除为尽力而为的清理操作,内部使用短超时避免网络异常长时间阻塞调用方。 +func FileDeleteCos(ctx context.Context, key string) error { + ctx, span := global.Tracer.Start(ctx, "upload.FileDeleteCos") + defer span.End() + + if key == "" { + return nil + } + + // COS 客户端初始化失败时降级为 nil,此处做保护避免 nil 指针 panic。 + if global.CosClient == nil { + err := fmt.Errorf("cos client not initialized") + span.RecordError(err) + return err + } + + delCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + _, err := global.CosClient.Object.Delete(delCtx, key) + if err != nil { + span.RecordError(err) + return err + } + + return nil +} + +// 获取cos访问url +func GetCosUrl(fileName string) string { + ts := time.Now().Unix() + raw := global.CosSetting.CdnDownloadSecretKey + "/" + fileName + strconv.FormatInt(ts, 10) + sign := fmt.Sprintf("%x", md5.Sum([]byte(raw))) + + return fmt.Sprintf("https://%s/%s?sign=%s&t=%d", + global.CosSetting.CosDownloadDomain, + fileName, + sign, + ts, + ) +} + +// AvatarURL 将头像存储值转为可访问 URL: +// 历史通过修改资料接口传入的 http(s) URL 直接透传;COS key 统一转为 CDN 签名 URL。 +func AvatarURL(raw string) string { + if raw == "" { + return "" + } + if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") { + return raw + } + return GetCosUrl(raw) +} diff --git a/pkg/wechat/mini.go b/pkg/wechat/mini.go new file mode 100644 index 0000000..7481516 --- /dev/null +++ b/pkg/wechat/mini.go @@ -0,0 +1,114 @@ +// Package wechat 封装微信小程序登录与微信支付 V3 的真实对接能力,仅依赖 Go 标准库。 +// +// 安全说明: +// - AppSecret / APIv3Key / 商户私钥均来自配置(禁止硬编码)。 +// - 仅访问微信固定域名(api.weixin.qq.com / api.mch.weixin.qq.com),无 SSRF 风险。 +package wechat + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" +) + +// code2SessionURL 微信小程序登录凭证校验接口。 +const code2SessionURL = "https://api.weixin.qq.com/sns/jscode2session" + +// code2SessionResp 微信 code2session 响应。 +type code2SessionResp struct { + OpenID string `json:"openid"` + SessionKey string `json:"session_key"` + UnionID string `json:"unionid"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +// Code2SessionResult code2session 结果。 +type Code2SessionResult struct { + OpenID string + SessionKey string + UnionID string +} + +// miniHTTPClient 小程序接口 HTTP 客户端(较短超时,微信接口通常很快)。 +var miniHTTPClient = &http.Client{Timeout: 10 * time.Second} + +// Code2Session 使用小程序登录 code 换取 openid / session_key。 +// +// 依赖配置 global.WechatMiniSetting.AppID / AppSecret。 +// 常见错误:40029 code 无效、45011 频率限制、40163 code 已使用。 +func Code2Session(ctx context.Context, code string) (*Code2SessionResult, error) { + if global.WechatMiniSetting == nil || global.WechatMiniSetting.AppID == "" || global.WechatMiniSetting.AppSecret == "" { + return nil, fmt.Errorf("wechat mini not configured") + } + + q := url.Values{} + q.Set("appid", global.WechatMiniSetting.AppID) + q.Set("secret", global.WechatMiniSetting.AppSecret) + q.Set("js_code", code) + q.Set("grant_type", "authorization_code") + + reqURL := code2SessionURL + "?" + q.Encode() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("code2session build request: %w", err) + } + + response, err := miniHTTPClient.Do(request) + if err != nil { + return nil, fmt.Errorf("code2session do request: %w", err) + } + defer response.Body.Close() + + data, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("code2session read response: %w", err) + } + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("code2session http status %d: %s", response.StatusCode, string(data)) + } + + var r code2SessionResp + if err := json.Unmarshal(data, &r); err != nil { + return nil, fmt.Errorf("code2session decode: %w", err) + } + if r.ErrCode != 0 { + return nil, &Code2SessionError{Code: r.ErrCode, Msg: r.ErrMsg} + } + if r.OpenID == "" { + return nil, fmt.Errorf("code2session empty openid") + } + + return &Code2SessionResult{ + OpenID: r.OpenID, + SessionKey: r.SessionKey, + UnionID: r.UnionID, + }, nil +} + +// Code2SessionError 微信 code2session 业务错误,携带 errcode 便于上层区分 code 无效等场景。 +type Code2SessionError struct { + Code int + Msg string +} + +func (e *Code2SessionError) Error() string { + return fmt.Sprintf("code2session errcode=%d, errmsg=%s", e.Code, e.Msg) +} + +// IsInvalidCode 判断是否为「code 无效 / 已使用 / 过期」类错误。 +func (e *Code2SessionError) IsInvalidCode() bool { + switch e.Code { + case 40029, // js_code 无效 + 40163, // code been used + 41008: // missing code + return true + } + return false +} diff --git a/pkg/wechat/pay.go b/pkg/wechat/pay.go new file mode 100644 index 0000000..c849bc3 --- /dev/null +++ b/pkg/wechat/pay.go @@ -0,0 +1,351 @@ +package wechat + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "sync" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting" + + "github.com/wechatpay-apiv3/wechatpay-go/core" + "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" + "github.com/wechatpay-apiv3/wechatpay-go/core/downloader" + "github.com/wechatpay-apiv3/wechatpay-go/core/notify" + "github.com/wechatpay-apiv3/wechatpay-go/core/option" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi" + "github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic" + "github.com/wechatpay-apiv3/wechatpay-go/utils" +) + +// payClient 微信支付客户端,基于官方 SDK(自动平台证书轮换 + 请求签名 + 应答/回调验签)。 +type payClient struct { + appID string + mchID string + notifyURL string + refundNotify string + client *core.Client + notifyHandler *notify.Handler +} + +var ( + defaultPayClient *payClient + payClientMu sync.RWMutex +) + +// InitPayClient 初始化全局微信支付客户端。 +// +// 使用官方 SDK 的 WithWechatPayAutoAuthCipher:加载商户私钥后自动注册平台证书下载器 +// (定期轮换),并具备请求签名、应答自动验签能力;同时构建回调处理器用于回调验签 + 解密。 +func InitPayClient(s *setting.WechatPaySettingS) error { + if s == nil { + return errors.New("wechat pay setting is nil") + } + if s.AppID == "" || s.MchID == "" || s.SerialNo == "" || s.APIv3Key == "" || s.PrivateKeyPath == "" { + return errors.New("wechat pay setting incomplete") + } + if len(s.APIv3Key) != 32 { + return fmt.Errorf("wechat pay APIv3Key must be 32 bytes, got %d", len(s.APIv3Key)) + } + + privateKey, err := utils.LoadPrivateKeyWithPath(s.PrivateKeyPath) + if err != nil { + return fmt.Errorf("load merchant private key: %w", err) + } + + ctx := context.Background() + // 创建客户端:内部注册平台证书自动下载器(轮换)、请求签名器与应答验签器。 + client, err := core.NewClient( + ctx, + option.WithWechatPayAutoAuthCipher(s.MchID, s.SerialNo, privateKey, s.APIv3Key), + ) + if err != nil { + return fmt.Errorf("new wechat pay client: %w", err) + } + + // 回调处理器:使用平台证书访问器进行回调验签,APIv3Key 进行解密。 + certVisitor := downloader.MgrInstance().GetCertificateVisitor(s.MchID) + notifyHandler, err := notify.NewRSANotifyHandler( + s.APIv3Key, + verifiers.NewSHA256WithRSAVerifier(certVisitor), + ) + if err != nil { + return fmt.Errorf("new wechat notify handler: %w", err) + } + + c := &payClient{ + appID: s.AppID, + mchID: s.MchID, + notifyURL: s.NotifyURL, + refundNotify: s.RefundNotifyURL, + client: client, + notifyHandler: notifyHandler, + } + + payClientMu.Lock() + defaultPayClient = c + payClientMu.Unlock() + return nil +} + +// getPayClient 获取已初始化的支付客户端。 +func getPayClient() (*payClient, error) { + payClientMu.RLock() + defer payClientMu.RUnlock() + if defaultPayClient == nil { + return nil, errors.New("wechat pay client not initialized") + } + return defaultPayClient, nil +} + +// MiniPayParams 小程序调起支付所需参数(对应 dto.WechatPayParams)。 +type MiniPayParams struct { + TimeStamp string + NonceStr string + Package string + SignType string + PaySign string +} + +// JsapiPrepay 调用微信 JSAPI 下单,一步返回小程序调起支付所需的签名参数。 +// - openid:支付用户在小程序 AppID 下的 openid +// - orderNo:商户订单号(out_trade_no) +// - description:商品描述 +// - amount:应付金额(分) +func JsapiPrepay(ctx context.Context, openid, orderNo, description string, amount int64) (*MiniPayParams, error) { + c, err := getPayClient() + if err != nil { + return nil, err + } + if openid == "" { + return nil, errors.New("empty openid") + } + + svc := jsapi.JsapiApiService{Client: c.client} + resp, _, err := svc.PrepayWithRequestPayment(ctx, jsapi.PrepayRequest{ + Appid: core.String(c.appID), + Mchid: core.String(c.mchID), + Description: core.String(description), + OutTradeNo: core.String(orderNo), + NotifyUrl: core.String(c.notifyURL), + Amount: &jsapi.Amount{ + Total: core.Int64(amount), + Currency: core.String("CNY"), + }, + Payer: &jsapi.Payer{ + Openid: core.String(openid), + }, + }) + if err != nil { + return nil, fmt.Errorf("jsapi prepay: %w", err) + } + + return &MiniPayParams{ + TimeStamp: strDeref(resp.TimeStamp), + NonceStr: strDeref(resp.NonceStr), + Package: strDeref(resp.Package), + SignType: strDeref(resp.SignType), + PaySign: strDeref(resp.PaySign), + }, nil +} + +// CloseOrder 关闭微信侧待支付订单,防止本地取消后用户仍完成支付。 +// - orderNo:商户订单号(out_trade_no) +func CloseOrder(ctx context.Context, orderNo string) error { + c, err := getPayClient() + if err != nil { + return err + } + if orderNo == "" { + return errors.New("empty order no") + } + + svc := jsapi.JsapiApiService{Client: c.client} + if _, err := svc.CloseOrder(ctx, jsapi.CloseOrderRequest{ + OutTradeNo: core.String(orderNo), + Mchid: core.String(c.mchID), + }); err != nil { + return fmt.Errorf("jsapi close order: %w", err) + } + return nil +} + +// RefundResult 退款受理结果。 +type RefundResult struct { + RefundID string // 微信退款单号 + Status string // SUCCESS/CLOSED/PROCESSING/ABNORMAL +} + +// Refund 申请退款。 +// - orderNo:原商户订单号 +// - refundNo:商户退款单号 +// - total:原订单总金额(分) +// - refund:本次退款金额(分) +func Refund(ctx context.Context, orderNo, refundNo string, total, refund int64, reason string) (*RefundResult, error) { + c, err := getPayClient() + if err != nil { + return nil, err + } + + req := refunddomestic.CreateRequest{ + OutTradeNo: core.String(orderNo), + OutRefundNo: core.String(refundNo), + Amount: &refunddomestic.AmountReq{ + Refund: core.Int64(refund), + Total: core.Int64(total), + Currency: core.String("CNY"), + }, + } + if c.refundNotify != "" { + req.NotifyUrl = core.String(c.refundNotify) + } + if reason != "" { + req.Reason = core.String(reason) + } + + svc := refunddomestic.RefundsApiService{Client: c.client} + resp, _, err := svc.Create(ctx, req) + if err != nil { + return nil, fmt.Errorf("refund create: %w", err) + } + + res := &RefundResult{RefundID: strDeref(resp.RefundId)} + if resp.Status != nil { + res.Status = string(*resp.Status) + } + return res, nil +} + +// ---- 回调验签 + 解密 ---- + +// PayNotifyData 支付成功回调解密后的关键字段。 +type PayNotifyData struct { + Raw string // 回调原始报文(验签前捕获,用于审计持久化) + OutTradeNo string + TransactionID string + TradeState string // SUCCESS 表示支付成功 + Amount struct { + Total int64 + PayerTotal int64 + Currency string + } + SuccessTime string +} + +// RefundNotifyData 退款回调解密后的关键字段。 +type RefundNotifyData struct { + Raw string // 回调原始报文(验签前捕获,用于审计持久化) + OutTradeNo string + OutRefundNo string + RefundID string + RefundStatus string // SUCCESS/CLOSED/ABNORMAL + SuccessTime string + Amount struct { + Total int64 + Refund int64 + } +} + +// refundNotifyContent 退款回调解密后的原始结构(退款通知使用 refund_status 字段)。 +type refundNotifyContent struct { + OutTradeNo string `json:"out_trade_no"` + OutRefundNo string `json:"out_refund_no"` + RefundID string `json:"refund_id"` + TransactionID string `json:"transaction_id"` + RefundStatus string `json:"refund_status"` + SuccessTime string `json:"success_time"` + Amount struct { + Total int64 `json:"total"` + Refund int64 `json:"refund"` + PayerTotal int64 `json:"payer_total"` + PayerRefund int64 `json:"payer_refund"` + } `json:"amount"` +} + +// VerifyAndDecryptPayNotify 校验并解密支付回调请求,返回订单数据。 +// +// 由官方 SDK 完成:校验签名头 → 平台证书验签 → APIv3Key 解密 → 反序列化,杜绝伪造/篡改回调。 +func VerifyAndDecryptPayNotify(ctx context.Context, request *http.Request) (*PayNotifyData, error) { + c, err := getPayClient() + if err != nil { + return nil, err + } + + // 验签前捕获原始报文用于审计持久化;ParseNotifyRequest 会消费 request.Body。 + raw, err := io.ReadAll(request.Body) + if err != nil { + return nil, fmt.Errorf("read pay notify body: %w", err) + } + request.Body = io.NopCloser(bytes.NewReader(raw)) + + tx := new(payments.Transaction) + if _, err := c.notifyHandler.ParseNotifyRequest(ctx, request, tx); err != nil { + return nil, fmt.Errorf("parse pay notify: %w", err) + } + + d := &PayNotifyData{ + Raw: string(raw), + OutTradeNo: strDeref(tx.OutTradeNo), + TransactionID: strDeref(tx.TransactionId), + TradeState: strDeref(tx.TradeState), + SuccessTime: strDeref(tx.SuccessTime), + } + if tx.Amount != nil { + d.Amount.Total = int64Deref(tx.Amount.Total) + d.Amount.PayerTotal = int64Deref(tx.Amount.PayerTotal) + d.Amount.Currency = strDeref(tx.Amount.Currency) + } + return d, nil +} + +// VerifyAndDecryptRefundNotify 校验并解密退款回调请求。 +func VerifyAndDecryptRefundNotify(ctx context.Context, request *http.Request) (*RefundNotifyData, error) { + c, err := getPayClient() + if err != nil { + return nil, err + } + + // 验签前捕获原始报文用于审计持久化;ParseNotifyRequest 会消费 request.Body。 + raw, err := io.ReadAll(request.Body) + if err != nil { + return nil, fmt.Errorf("read refund notify body: %w", err) + } + request.Body = io.NopCloser(bytes.NewReader(raw)) + + content := new(refundNotifyContent) + if _, err := c.notifyHandler.ParseNotifyRequest(ctx, request, content); err != nil { + return nil, fmt.Errorf("parse refund notify: %w", err) + } + + d := &RefundNotifyData{ + Raw: string(raw), + OutTradeNo: content.OutTradeNo, + OutRefundNo: content.OutRefundNo, + RefundID: content.RefundID, + RefundStatus: content.RefundStatus, + SuccessTime: content.SuccessTime, + } + d.Amount.Total = content.Amount.Total + d.Amount.Refund = content.Amount.Refund + return d, nil +} + +// ---- 内部辅助 ---- + +func strDeref(s *string) string { + if s == nil { + return "" + } + return *s +} + +func int64Deref(i *int64) int64 { + if i == nil { + return 0 + } + return *i +} diff --git a/pkg/wechat/virtualpay.go b/pkg/wechat/virtualpay.go new file mode 100644 index 0000000..a1c3e7d --- /dev/null +++ b/pkg/wechat/virtualpay.go @@ -0,0 +1,553 @@ +// Package wechat 微信小程序虚拟支付(XPay)客户端。 +// +// 与普通微信支付(V3)完全独立: +// - 服务器 API 前缀 /xpay/,网关 https://api.weixin.qq.com; +// - 签名用 HMAC-SHA256(appKey):pay_sig = hex(hmac_sha256(appKey, uri + "&" + postBody)), +// uri 为接口路径(不含 query),postBody 与真实请求体逐字节一致; +// - 前端调起 wx.requestVirtualPayment 由服务端生成 signData / paySig / signature, +// 其中 paySig 的 uri 固定为 "requestVirtualPayment",signature 用用户 session_key; +// - 结果通知走小程序消息推送(Event 通道),非 V3 的 RSA 验签 + AES 解密回调, +// 应答格式为 {"ErrCode":0,"ErrMsg":"success"}。 +// +// 安全说明:AppKey / AppSecret 均来自配置(禁止硬编码),仅访问微信固定域名。 +// +// 注意:/xpay/ 各接口的字段以微信官方「虚拟支付」服务端 API 文档为准, +// 本文件已按官方文档与社区 SDK 对齐;微信后台未开通前,接口调用需联调时再核对字段。 +package wechat + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "sync" + "time" + + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/global" + "git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting" +) + +// xpay 网关与接口路径。 +const ( + xpayBaseURL = "https://api.weixin.qq.com" + stableTokenURL = "https://api.weixin.qq.com/cgi-bin/stable_token" + + xpayQueryOrderPath = "/xpay/query_order" + xpayRefundOrderPath = "/xpay/refund_order" + + // requestVirtualPaymentURI 前端调起支付时计算 paySig 使用的固定 uri。 + requestVirtualPaymentURI = "requestVirtualPayment" +) + +// 虚拟支付环境。 +const ( + XPayEnvProd = 0 // 现网(正式环境) + XPayEnvSandbox = 1 // 沙箱(iOS 不支持沙箱,默认不启用) +) + +// virtualPayClient 虚拟支付客户端(全局单例)。 +type virtualPayClient struct { + offerID string // 虚拟支付 OfferID(即小程序 AppID) + appKey string // 激活环境的 AppKey(按配置自动选择:现网优先,仅填沙箱时用沙箱 key) + env int // 激活环境 0=现网 1=沙箱 + token string // 消息推送验签 Token(配置回调地址时自定义) + + appID string // 获取 access_token 用的小程序 AppID + appSecret string // 小程序 AppSecret + + httpClient *http.Client + + accessMu sync.Mutex + accessToken string + accessExp time.Time +} + +var ( + defaultVirtualPayClient *virtualPayClient + virtualPayMu sync.RWMutex +) + +// resolveEnvKey 按配置决定激活环境与 AppKey: +// - EnvAppKey 非空 -> 现网(env=0),两把都填时现网优先; +// - EnvAppKey 空且 SandboxAppKey 非空 -> 沙箱(env=1); +// - 两把都空 -> 返回错误。 +func resolveEnvKey(envAppKey, sandboxAppKey string) (appKey string, env int, err error) { + if envAppKey != "" { + return envAppKey, XPayEnvProd, nil + } + if sandboxAppKey != "" { + return sandboxAppKey, XPayEnvSandbox, nil + } + return "", 0, errors.New("virtual pay app key not configured: both env_app_key and sandbox_app_key are empty") +} + +// InitVirtualPayClient 初始化全局虚拟支付客户端。 +// 复用 global.WechatMiniSetting 的 AppID/AppSecret 获取 access_token(同一小程序)。 +// 环境选择:只填沙箱 AppKey 走沙箱(env=1,测试不真实扣款);只填现网或两把都填走现网(env=0)。 +func InitVirtualPayClient(s *setting.VirtualPaySettingS) error { + if s == nil || !s.Enable { + return errors.New("virtual pay setting is nil or disabled") + } + if s.OfferID == "" { + return errors.New("virtual pay setting incomplete: offer_id is required") + } + appKey, env, err := resolveEnvKey(s.EnvAppKey, s.SandboxAppKey) + if err != nil { + return err + } + if global.WechatMiniSetting == nil || global.WechatMiniSetting.AppID == "" || global.WechatMiniSetting.AppSecret == "" { + return errors.New("wechat mini setting incomplete, cannot fetch virtual pay access_token") + } + + c := &virtualPayClient{ + offerID: s.OfferID, + appKey: appKey, + env: env, + token: s.Token, + appID: global.WechatMiniSetting.AppID, + appSecret: global.WechatMiniSetting.AppSecret, + httpClient: &http.Client{Timeout: 10 * time.Second}, + } + + virtualPayMu.Lock() + defaultVirtualPayClient = c + virtualPayMu.Unlock() + return nil +} + +// GetVirtualPayEnv 返回当前激活的虚拟支付环境(0=现网 1=沙箱)。 +func GetVirtualPayEnv() (int, error) { + c, err := getVirtualPayClient() + if err != nil { + return 0, err + } + return c.env, nil +} + +// getVirtualPayClient 获取已初始化的虚拟支付客户端。 +func getVirtualPayClient() (*virtualPayClient, error) { + virtualPayMu.RLock() + defer virtualPayMu.RUnlock() + if defaultVirtualPayClient == nil { + return nil, errors.New("virtual pay client not initialized") + } + return defaultVirtualPayClient, nil +} + +// IsVirtualPayEnabled 虚拟支付是否已初始化(供上层判断是否可用)。 +func IsVirtualPayEnabled() bool { + virtualPayMu.RLock() + defer virtualPayMu.RUnlock() + return defaultVirtualPayClient != nil +} + +// ---- 签名与工具 ---- + +// hmacSHA256Hex 计算 HMAC-SHA256 并返回小写 hex。 +func hmacSHA256Hex(key, data string) string { + h := hmac.New(sha256.New, []byte(key)) + _, _ = h.Write([]byte(data)) + return hex.EncodeToString(h.Sum(nil)) +} + +// xpayAPIError xpay 接口业务错误。 +type xpayAPIError struct { + Code int + Msg string +} + +func (e *xpayAPIError) Error() string { + return fmt.Sprintf("xpay errcode=%d, errmsg=%s", e.Code, e.Msg) +} + +// stableTokenResp stable_token 响应。 +type stableTokenResp struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +// getAccessToken 获取并缓存 access_token(提前 300 秒刷新)。 +func (c *virtualPayClient) getAccessToken(ctx context.Context) (string, error) { + c.accessMu.Lock() + defer c.accessMu.Unlock() + + if c.accessToken != "" && time.Now().Before(c.accessExp.Add(-300*time.Second)) { + return c.accessToken, nil + } + + body, _ := json.Marshal(map[string]string{ + "grant_type": "client_credential", + "appid": c.appID, + "secret": c.appSecret, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, stableTokenURL, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("stable_token build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("stable_token do request: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("stable_token read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("stable_token http status %d: %s", resp.StatusCode, string(data)) + } + + var r stableTokenResp + if err := json.Unmarshal(data, &r); err != nil { + return "", fmt.Errorf("stable_token decode: %w", err) + } + if r.ErrCode != 0 || r.AccessToken == "" { + return "", &xpayAPIError{Code: r.ErrCode, Msg: r.ErrMsg} + } + + c.accessToken = r.AccessToken + expire := r.ExpiresIn + if expire <= 0 { + expire = 7200 + } + c.accessExp = time.Now().Add(time.Duration(expire) * time.Second) + return c.accessToken, nil +} + +// callXPay 向 /xpay/ 接口发起 POST 请求并解码响应。 +// 签名使用激活环境的 AppKey(现网或沙箱,按配置自动选择);body 为请求体结构, +// out 为响应结构(可为 nil 表示忽略响应体)。 +func (c *virtualPayClient) callXPay(ctx context.Context, uri string, body any, out any) error { + bodyBytes, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("xpay marshal body: %w", err) + } + + token, err := c.getAccessToken(ctx) + if err != nil { + return err + } + + // pay_sig = hex(hmac_sha256(appKey, uri + "&" + body)),body 与真实请求体逐字节一致。 + paySig := hmacSHA256Hex(c.appKey, uri+"&"+string(bodyBytes)) + + q := url.Values{} + q.Set("access_token", token) + q.Set("pay_sig", paySig) + + reqURL := xpayBaseURL + uri + "?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("xpay build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("xpay do request: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("xpay read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("xpay http status %d: %s", resp.StatusCode, string(data)) + } + + // 先解一层通用错误码(若存在),避免把业务错误误判为成功。 + var errChk struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := json.Unmarshal(data, &errChk); err == nil && errChk.ErrCode != 0 { + return &xpayAPIError{Code: errChk.ErrCode, Msg: errChk.ErrMsg} + } + + if out != nil { + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("xpay decode response: %w", err) + } + } + return nil +} + +// ---- 订单查询 / 退款 ---- + +// XPayOrderInfo 虚拟支付订单查询结果。 +type XPayOrderInfo struct { + OrderID string `json:"order_id"` // 商户订单号 + WxOrderID string `json:"wx_order_id"` // 微信订单号 + OpenID string `json:"openid"` // 用户 openid + ProductID string `json:"product_id"` // 道具 ID + Quantity int64 `json:"quantity"` // 购买数量 + PayFee int64 `json:"pay_fee"` // 实付金额(分) + Status string `json:"status"` // 订单状态(以官方枚举为准) + RefundStatus string `json:"refund_status"` // 退款状态(以官方枚举为准,待确认字段名) + RefundFee int64 `json:"refund_fee"` // 退款金额(分,待确认字段名) + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +// 虚拟支付订单支付状态(query_order 返回的 status 字段,以微信官方枚举为准,联调时校正)。 +const ( + XPayOrderStatusPaid = "PAID" // 已支付 +) + +// IsOrderPaidStatus 判断 query_order 返回的订单状态是否为「已支付」。 +// 白名单判断:仅命中已知成功枚举返回 true;未知状态/空串一律视为未支付, +// 供发货链路 fail-closed 使用(判定为未支付的推送会被拒绝并交由微信重试)。 +func IsOrderPaidStatus(status string) bool { + return status == XPayOrderStatusPaid +} + +// 虚拟支付订单退款状态(query_order 返回的 refund_status 字段,以微信官方枚举为准,联调时校正)。 +const ( + XPayOrderRefundedStatus = "REFUNDED" // 已退款 +) + +// IsOrderRefundedStatus 判断 query_order 返回的订单退款状态是否为「已退款」。 +// 白名单判断:仅命中已知成功枚举返回 true;未知状态/空串一律视为未退款, +// 供退款链路 fail-closed 使用(判定为未退款的推送会被拒绝并交由微信重试)。 +func IsOrderRefundedStatus(status string) bool { + return status == XPayOrderRefundedStatus +} + +// 虚拟支付退款推送的退款状态(RefundStatus 字段,以微信官方枚举为准,联调时校正)。 +const ( + XPayRefundStatusSuccess = "SUCCESS" // 退款成功 +) + +// QueryOrder 查询虚拟支付订单(orderID 与 wxOrderID 二选一,非空者优先;作用于激活环境)。 +func QueryOrder(ctx context.Context, openid, orderID, wxOrderID string) (*XPayOrderInfo, error) { + c, err := getVirtualPayClient() + if err != nil { + return nil, err + } + body := map[string]any{"openid": openid, "env": c.env} + if orderID != "" { + body["order_id"] = orderID + } else { + body["wx_order_id"] = wxOrderID + } + out := &XPayOrderInfo{} + if err := c.callXPay(ctx, xpayQueryOrderPath, body, out); err != nil { + return nil, err + } + return out, nil +} + +// RefundOrder 启动虚拟支付退款任务(仅启动任务,结果需轮询 QueryOrder;作用于激活环境)。 +// - leftFee:剩余金额(分),即退款前的订单实付金额; +// - refundFee:本次退款金额(分)。 +func RefundOrder(ctx context.Context, openid, orderID, refundOrderID string, leftFee, refundFee int64, reason string) error { + c, err := getVirtualPayClient() + if err != nil { + return err + } + body := map[string]any{ + "openid": openid, + "env": c.env, + "order_id": orderID, + "refund_order_id": refundOrderID, + "left_fee": leftFee, + "refund_fee": refundFee, + "refund_reason": reason, + } + return c.callXPay(ctx, xpayRefundOrderPath, body, nil) +} + +// ---- 前端调起签名 ---- + +// VirtualPaySign 返回给前端调起 wx.requestVirtualPayment 的签名数据。 +type VirtualPaySign struct { + SignData string `json:"sign_data"` // 参与签名的 JSON 字符串(原样传给前端,勿重新序列化) + PaySig string `json:"pay_sig"` + Signature string `json:"signature"` +} + +// BuildPaymentSign 生成前端调起虚拟支付的 signData / paySig / signature。 +// +// signData 字段按字典序排列(微信侧按字典序重组验签,顺序敏感),offerId 必须为字符串; +// env 与签名 key 使用客户端激活环境(现网或沙箱)。 +// - paySig = hex(hmac_sha256(appKey, "requestVirtualPayment" + "&" + signData)) +// - signature = hex(hmac_sha256(sessionKey, signData)) +func BuildPaymentSign(offerID, productID, outTradeNo, attach, sessionKey string, goodsPrice int64) (*VirtualPaySign, error) { + if offerID == "" || productID == "" || outTradeNo == "" { + return nil, errors.New("virtual pay sign: offer_id, product_id, out_trade_no are required") + } + c, err := getVirtualPayClient() + if err != nil { + return nil, err + } + + fields := map[string]any{ + "offerId": offerID, + "buyQuantity": 1, + "currencyType": "CNY", + "env": c.env, + "goodsPrice": goodsPrice, + "productId": productID, + "outTradeNo": outTradeNo, + } + if attach != "" { + fields["attach"] = attach + } + + // 按字典序排序字段名后序列化,保证与微信侧重组结果一致。 + keys := make([]string, 0, len(fields)) + for k := range fields { + keys = append(keys, k) + } + sort.Strings(keys) + + var b bytes.Buffer + b.WriteByte('{') + for i, k := range keys { + if i > 0 { + b.WriteByte(',') + } + kb, _ := json.Marshal(k) + vb, _ := json.Marshal(fields[k]) + b.Write(kb) + b.WriteByte(':') + b.Write(vb) + } + b.WriteByte('}') + signData := b.String() + + paySig := hmacSHA256Hex(c.appKey, requestVirtualPaymentURI+"&"+signData) + signature := hmacSHA256Hex(sessionKey, signData) + + return &VirtualPaySign{SignData: signData, PaySig: paySig, Signature: signature}, nil +} + +// ---- 消息推送 ---- + +// 虚拟支付消息推送 Event 类型。 +const ( + EventGoodsDeliverNotify = "xpay_goods_deliver_notify" // 道具发货推送 + EventCoinPayNotify = "xpay_coin_pay_notify" // 代币支付推送 + EventRefundNotify = "xpay_refund_notify" // 退款推送 + EventComplaintNotify = "xpay_complaint_notify" // 用户投诉推送 + EventWxpayCallbackNotify = "xpay_wxpay_callback_notify" // 微信支付风控事件 + EventIOSRefundQueryNotify = "xpay_subscribe_ios_refund_query_notify" // iOS 退款问询推送 +) + +// XPayNotifyBase 消息推送公共字段。 +type XPayNotifyBase struct { + ToUserName string `json:"ToUserName"` + FromUserName string `json:"FromUserName"` + CreateTime int64 `json:"CreateTime"` + MsgType string `json:"MsgType"` + Event string `json:"Event"` + OpenID string `json:"OpenId"` + OutTradeNo string `json:"OutTradeNo"` + Env int `json:"Env"` +} + +// XPayGoodsDeliverNotify 道具发货推送。 +type XPayGoodsDeliverNotify struct { + XPayNotifyBase + WeChatPayInfo struct { + MchOrderNo string `json:"MchOrderNo"` // 微信支付商户单号 + TransactionID string `json:"TransactionId"` // 微信支付交易单号 + PaidTime int64 `json:"PaidTime"` // 支付时间(Linux 秒级时间戳) + } `json:"WeChatPayInfo"` + GoodsInfo struct { + ProductID string `json:"ProductId"` // 道具 ID + Quantity int64 `json:"Quantity"` // 购买数量 + OrigPrice int64 `json:"OrigPrice"` // 原始价格(分) + ActualPrice int64 `json:"ActualPrice"` // 实际支付价格(分) + Attach string `json:"Attach"` // 透传信息 + } `json:"GoodsInfo"` +} + +// XPayRefundNotify 退款推送。 +type XPayRefundNotify struct { + XPayNotifyBase + RefundStatus string `json:"RefundStatus"` // 退款状态(以官方枚举为准) + RefundFee int64 `json:"RefundFee"` // 退款金额(分) + RefundOrderID string `json:"RefundOrderId"` // 商户退款单号 +} + +// XPayIOSRefundQueryNotify iOS 退款问询推送。 +type XPayIOSRefundQueryNotify struct { + XPayNotifyBase + // 字段以官方文档为准;此处解析退款问询所需的最小信息。 + RefundOrderID string `json:"RefundOrderId"` + RefundFee int64 `json:"RefundFee"` +} + +// ParseNotify 解析消息推送 JSON 并返回事件类型与公共字段。 +// 返回值为 base(公共字段)+ 原始 body,调用方再按 Event 分发到具体结构。 +func ParseNotify(raw []byte) (*XPayNotifyBase, error) { + if len(raw) == 0 { + return nil, errors.New("empty xpay notify body") + } + base := &XPayNotifyBase{} + if err := json.Unmarshal(raw, base); err != nil { + return nil, fmt.Errorf("parse xpay notify: %w", err) + } + if base.Event == "" { + return nil, errors.New("xpay notify missing event") + } + return base, nil +} + +// BuildNotifyResponse 构造消息推送应答(成功/失败)。 +func BuildNotifyResponse(success bool) []byte { + if success { + return []byte(`{"ErrCode":0,"ErrMsg":"success"}`) + } + return []byte(`{"ErrCode":1,"ErrMsg":"处理失败"}`) +} + +// BuildIOSRefundQueryResponse 构造 iOS 退款问询应答(3 秒内必须返回)。 +// resultCode: 0 建议退款、1 拒绝退款;evidence 为决策凭据。 +// 字段名以官方文档为准(result_code / evidence)。 +// 使用 json.Marshal 保证字符串转义符合 JSON 规范(避免 fmt %q 的 Go 风格转义)。 +func BuildIOSRefundQueryResponse(resultCode int, evidence string) []byte { + b, _ := json.Marshal(map[string]any{ + "result_code": resultCode, + "evidence": evidence, + }) + return b +} + +// CheckURLSignature 校验消息推送 URL 配置时的 GET 验签(SHA1)。 +// token 为配置回调地址时自定义的 Token;通过则返回 true 并原样回显 echostr。 +func CheckURLSignature(token, timestamp, nonce, signature string) bool { + if token == "" || signature == "" { + return false + } + arr := []string{token, timestamp, nonce} + sort.Strings(arr) + joined := arr[0] + arr[1] + arr[2] + h := sha1.Sum([]byte(joined)) + return hex.EncodeToString(h[:]) == signature +} + +// VerifyNotifyURL 校验消息推送 URL 配置时的 GET 验签(使用已初始化的 Token)。 +func VerifyNotifyURL(timestamp, nonce, signature string) bool { + c, err := getVirtualPayClient() + if err != nil { + return false + } + return CheckURLSignature(c.token, timestamp, nonce, signature) +} diff --git a/pkg/wechat/virtualpay_test.go b/pkg/wechat/virtualpay_test.go new file mode 100644 index 0000000..f34d8f0 --- /dev/null +++ b/pkg/wechat/virtualpay_test.go @@ -0,0 +1,191 @@ +package wechat + +import ( + "crypto/sha1" + "encoding/hex" + "encoding/json" + "sort" + "strings" + "testing" +) + +// TestHMACSHA256Hex 用 RFC 4231 测试向量校验 HMAC-SHA256 实现。 +func TestHMACSHA256Hex(t *testing.T) { + key := strings.Repeat("\x0b", 20) // 0x0b * 20 + data := "Hi There" + want := "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" + if got := hmacSHA256Hex(key, data); got != want { + t.Fatalf("hmacSHA256Hex mismatch: got %s, want %s", got, want) + } +} + +// TestBuildPaymentSign 校验 signData 字典序、paySig 与 signature 的内部一致性。 +func TestBuildPaymentSign(t *testing.T) { + const ( + appKey = "test-app-key" + sessionKey = "test-session-key" + offerID = "wx1234567890" + productID = "monthly_vip" + outTradeNo = "OD20260908120000abcdef" + attach = "uid=42" + ) + + virtualPayMu.Lock() + defaultVirtualPayClient = &virtualPayClient{appKey: appKey, env: XPayEnvProd} + virtualPayMu.Unlock() + defer func() { + virtualPayMu.Lock() + defaultVirtualPayClient = nil + virtualPayMu.Unlock() + }() + + sign, err := BuildPaymentSign(offerID, productID, outTradeNo, attach, sessionKey, 1900) + if err != nil { + t.Fatalf("BuildPaymentSign error: %v", err) + } + + // signData 字段必须按字典序排列(微信侧按字典序重组验签)。 + wantOrder := []string{"attach", "buyQuantity", "currencyType", "env", "goodsPrice", "offerId", "outTradeNo", "productId"} + last := -1 + for _, key := range wantOrder { + idx := strings.Index(sign.SignData, `"`+key+`"`) + if idx < 0 { + t.Fatalf("signData missing key %q: %s", key, sign.SignData) + } + if idx < last { + t.Fatalf("signData key order wrong at %q: %s", key, sign.SignData) + } + last = idx + } + + // signData 必须是合法 JSON,且 offerId 为字符串。 + var m map[string]any + if err := json.Unmarshal([]byte(sign.SignData), &m); err != nil { + t.Fatalf("signData is not valid JSON: %v", err) + } + if _, ok := m["offerId"].(string); !ok { + t.Fatalf("offerId must be string, got %T", m["offerId"]) + } + if v, ok := m["goodsPrice"].(float64); !ok || v != 1900 { + t.Fatalf("goodsPrice mismatch: %v", m["goodsPrice"]) + } + + // paySig = hmac(appKey, "requestVirtualPayment&"+signData);signature = hmac(sessionKey, signData)。 + wantPaySig := hmacSHA256Hex(appKey, requestVirtualPaymentURI+"&"+sign.SignData) + wantSignature := hmacSHA256Hex(sessionKey, sign.SignData) + if sign.PaySig != wantPaySig { + t.Fatalf("paySig mismatch: got %s, want %s", sign.PaySig, wantPaySig) + } + if sign.Signature != wantSignature { + t.Fatalf("signature mismatch: got %s, want %s", sign.Signature, wantSignature) + } +} + +// TestCheckURLSignature 校验消息推送 URL 验签(SHA1 字典序拼接)。 +func TestCheckURLSignature(t *testing.T) { + token := "my-token" + timestamp := "1756000000" + nonce := "abc123" + + // 独立计算期望值:["1756000000","abc123","my-token"] 字典序拼接后做 SHA1。 + arr := []string{token, timestamp, nonce} + sort.Strings(arr) + sum := sha1.Sum([]byte(arr[0] + arr[1] + arr[2])) + wantSig := hex.EncodeToString(sum[:]) + + if !CheckURLSignature(token, timestamp, nonce, wantSig) { + t.Fatal("CheckURLSignature should pass with correct signature") + } + if CheckURLSignature(token, timestamp, nonce, "bad") { + t.Fatal("CheckURLSignature should fail with wrong signature") + } + if CheckURLSignature("", timestamp, nonce, wantSig) { + t.Fatal("CheckURLSignature should fail with empty token") + } +} + +// TestParseNotifyAndResponse 校验发货推送解析与应答构造。 +func TestParseNotifyAndResponse(t *testing.T) { + raw := []byte(`{ + "ToUserName":"gh_test", + "FromUserName":"official_openid", + "CreateTime":1756000000, + "MsgType":"event", + "Event":"xpay_goods_deliver_notify", + "OpenId":"o_user", + "OutTradeNo":"OD20260908120000abcdef", + "Env":0, + "WeChatPayInfo":{"MchOrderNo":"mch_123","TransactionId":"wx_123","PaidTime":1756000000}, + "GoodsInfo":{"ProductId":"monthly_vip","Quantity":1,"OrigPrice":1900,"ActualPrice":1900,"Attach":"uid=42"} + }`) + + base, err := ParseNotify(raw) + if err != nil { + t.Fatalf("ParseNotify error: %v", err) + } + if base.Event != EventGoodsDeliverNotify { + t.Fatalf("unexpected event: %s", base.Event) + } + if base.OutTradeNo != "OD20260908120000abcdef" { + t.Fatalf("unexpected out_trade_no: %s", base.OutTradeNo) + } + + var deliver XPayGoodsDeliverNotify + if err := json.Unmarshal(raw, &deliver); err != nil { + t.Fatalf("unmarshal deliver notify: %v", err) + } + if deliver.GoodsInfo.ProductID != "monthly_vip" || deliver.GoodsInfo.ActualPrice != 1900 { + t.Fatalf("deliver goods info mismatch: %+v", deliver.GoodsInfo) + } + + okBody := BuildNotifyResponse(true) + if !strings.Contains(string(okBody), `"ErrCode":0`) { + t.Fatalf("success response missing ErrCode 0: %s", okBody) + } + failBody := BuildNotifyResponse(false) + if !strings.Contains(string(failBody), `"ErrCode":1`) { + t.Fatalf("fail response missing ErrCode 1: %s", failBody) + } +} + +// TestIsOrderPaidStatus 校验订单支付状态白名单判断:仅已知成功枚举为已支付,未知/空串按未支付。 +func TestIsOrderPaidStatus(t *testing.T) { + cases := []struct { + status string + want bool + }{ + {XPayOrderStatusPaid, true}, + {"NOTPAY", false}, + {"CLOSED", false}, + {"UNKNOWN", false}, + {"", false}, + } + for _, c := range cases { + if got := IsOrderPaidStatus(c.status); got != c.want { + t.Fatalf("IsOrderPaidStatus(%q) = %v, want %v", c.status, got, c.want) + } + } +} + +// TestResolveEnvKey 校验环境自动选择规则:只填现网/只填沙箱/两把都填/两把都空。 +func TestResolveEnvKey(t *testing.T) { + // 只填现网 -> 现网 + key, env, err := resolveEnvKey("prod-key", "") + if err != nil || key != "prod-key" || env != XPayEnvProd { + t.Fatalf("only env key: key=%q env=%d err=%v", key, env, err) + } + // 只填沙箱 -> 沙箱 + key, env, err = resolveEnvKey("", "sandbox-key") + if err != nil || key != "sandbox-key" || env != XPayEnvSandbox { + t.Fatalf("only sandbox key: key=%q env=%d err=%v", key, env, err) + } + // 两把都填 -> 现网优先 + key, env, err = resolveEnvKey("prod-key", "sandbox-key") + if err != nil || key != "prod-key" || env != XPayEnvProd { + t.Fatalf("both keys: key=%q env=%d err=%v", key, env, err) + } + // 两把都空 -> 报错 + if _, _, err := resolveEnvKey("", ""); err == nil { + t.Fatal("empty keys should return error") + } +} diff --git a/resources/watch_gshock_test.jpg b/resources/watch_gshock_test.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fc1b04acc17358933ed67a71bd092789dd5fd1ea GIT binary patch literal 359301 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

&Rh4zJk;O}xHlM`EshUutQ^BQz#yg#pOvW0U7 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 z

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�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 z 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ñt=;do{ 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%0AEEoyCKv@<7b6qmw=iOu0vPv89UV+Bc%=!w( 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μXJgn|VnYKd%d(sJSxisXQ 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 literal 0 HcmV?d00001 diff --git a/resources/微信图片_20260907235100_780_53.jpg b/resources/微信图片_20260907235100_780_53.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5f550c564471c29ffa85605397812674d12a5066 GIT binary patch literal 61187 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 M#eu}C3Z&8h0hh9!ApigX literal 0 HcmV?d00001 diff --git a/resources/纸条AI衣橱小程序头像生成.jpg b/resources/纸条AI衣橱小程序头像生成.jpg new file mode 100644 index 0000000000000000000000000000000000000000..07ea3575b0e54b08cbe3408230586992398236a9 GIT binary patch literal 183683 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�@c26Mzb$*>FvB=Gk3xrH< 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 literal 0 HcmV?d00001 diff --git a/scripts/nginx/nginx.conf b/scripts/nginx/nginx.conf new file mode 100644 index 0000000..508c897 --- /dev/null +++ b/scripts/nginx/nginx.conf @@ -0,0 +1,57 @@ +# ============================================================ +# zt-closet-backend 反向代理配置 +# 后端监听 127.0.0.1:8080,nginx 对外 80(跳转 443)+ 443(HTTPS) +# ============================================================ + +# 上传大小限制(衣物图片上传最大 20MB,multipart 边界 + 后端 MaxBytesReader 余量) +client_max_body_size 25m; + +# ---------- HTTP 80:强制跳转 HTTPS ---------- +server { + listen 80; + server_name your.domain.com; + + # 微信回调、xpay 推送等也一并走 HTTPS + location / { + return 301 https://$host$request_uri; + } +} + +# ---------- HTTPS 443:SSL + 反向代理 ---------- +server { + listen 443 ssl; + http2 on; + server_name your.domain.com; + + # SSL 证书(占位,替换为你的真实路径) + ssl_certificate /etc/nginx/ssl/your.domain.com.pem; + ssl_certificate_key /etc/nginx/ssl/your.domain.com.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 上传大小限制(覆盖全局,明确写在这里更直观) + client_max_body_size 25m; + + # 反向代理到后端 + location / { + proxy_pass http://127.0.0.1:8080; + + # 透传真实客户端信息与链路请求 ID + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-ID $request_id; # 后端 RequestID 中间件会透传 + + # 超时:AI 推荐等接口需调大模型,给足余量(后端 WriteTimeout 默认 60s) + proxy_connect_timeout 10s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + + # 上传大文件建议关闭请求体缓冲,降低延迟与磁盘占用 + proxy_request_buffering off; + } +} diff --git a/thirdparty/classification_server/main.py b/thirdparty/classification_server/main.py new file mode 100644 index 0000000..6713053 --- /dev/null +++ b/thirdparty/classification_server/main.py @@ -0,0 +1,611 @@ +"""Fashion-CLIP 衣物分类服务 + +使用 patrickjohncyh/fashion-clip 模型进行零样本分类, +一次性识别衣物的 15 个维度: +大类 / 子类别 / 风格 / 主颜色 / 副颜色 / 厚度 / 版型 / 材质 / 花纹 / +袖型 / 衣长类型 / 领型 / 正式程度(1-5) / 适用场合 / 视觉冲击力(1-5)。 + +所有维度在一次模型前向中完成(约 210 个提示词拼接为一个 batch, +按组 softmax 归一化),响应字段与后端 clothes 表模型命名对齐。 + +模型首次运行从 HuggingFace Hub 下载权重并缓存到本地。 +默认使用 hf-mirror.com 镜像(国内加速)。 +""" + +import io +import logging +import os + +# !!!必须在 import transformers 之前设置,否则 huggingface_hub 读不到这个环境变量!!! +if "HF_ENDPOINT" not in os.environ: + os.environ["HF_ENDPOINT"] = "https://hf-mirror.com" + +import torch +from fastapi import FastAPI, HTTPException, UploadFile +from PIL import Image +from transformers import CLIPModel, CLIPProcessor + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="Fashion-CLIP Classification Service") + +# ============================================================================ +# 分类提示词 +# 说明: +# - Fashion-CLIP 是英文训练的,用英文+视觉特征描述效果远好于简单中文标签。 +# - 键为中文标签(返回给客户端,与 clothes 表字段取值一致),值为英文提示词。 +# - 描述尽量包含剪裁、长度、材质视觉特征与穿着场景,提升零样本区分度。 +# ============================================================================ + +CATEGORY_PROMPTS = { + "上装": "a short top, t-shirt, shirt, blouse or sweater worn on the upper body, garment covering the torso from shoulders to waist or hips", + "下装": "a pair of pants, trousers, jeans, shorts, leggings or a skirt worn on the lower body below the waist", + "连衣裙": "a one-piece dress worn from shoulders down, covering both upper and lower body in a single connected garment", + "外套": "a jacket, coat, blazer or outerwear worn as an outer layer over other clothes, with sleeves and front opening", + "鞋": "a pair of shoes, sneakers, boots, sandals or slippers worn on the feet, a full shoe shape with sole, heel and toe, footwear covering the foot", + "配饰": "a small personal accessory item like a hat, scarf, belt, bag, sunglasses, necklace, earrings, bracelet, wristwatch or other jewelry, not a main clothing garment", +} + +# 子类别按大类分组:大类确定后在其对应组内取 top1,避免牛仔裤出现在上装里 +SUBCATEGORY_GROUPS = { + "上装": { + "T恤": "a short-sleeve cotton t-shirt with round crew neck, casual basic top, no buttons no collar", + "衬衫": "a button-up shirt with collar and front buttons, long or short sleeves, woven fabric with cuffs", + "卫衣": "a hoodie or sweatshirt, thick fleece cotton pullover with or without hood and kangaroo pocket", + "毛衣": "a thick knitted wool sweater or pullover, chunky warm textured knit top", + "针织衫": "a thin fine-knit top, ribbed lightweight knitwear, fitted casual knit shirt, finer yarn than a chunky sweater", + "背心": "a sleeveless tank top with wide shoulder coverage, sporty or basic vest top", + "Polo衫": "a polo shirt with flat collar, short sleeves and a 2-3 button placket, sporty casual", + "开衫": "a cardigan, open-front knitted sweater-like top with buttons or zipper down the front", + "吊带衫": "a camisole top with thin spaghetti straps, sleeveless lightweight top with narrow shoulder straps", + "打底衫": "a basic fitted long-sleeve layering top, thin turtleneck or crew neck undershirt, plain solid color", + }, + "下装": { + "牛仔裤": "a pair of blue denim jeans, casual pants with visible seams, rivets and five pockets", + "休闲裤": "a pair of casual cotton or chino trousers, straight leg comfortable pants, no denim texture", + "运动裤": "a pair of sporty sweatpants or joggers with elastic waistband, relaxed athletic fit", + "短裤": "a pair of shorts ending above the knee, short pants for warm weather", + "半身裙": "a skirt worn from waist down, flared A-line or straight or pleated silhouette", + "阔腿裤": "a pair of wide-leg loose flowing trousers with very wide leg openings", + "西装裤": "a pair of formal tailored dress trousers with center crease, suit pants", + "鲨鱼裤": "a pair of tight shiny yoga leggings, skin-tight stretch pants, sporty smooth surface", + "工装裤": "a pair of cargo pants with large side pockets on the legs, utility workwear style", + "喇叭裤": "a pair of flared pants fitted at thigh and widening from knee to hem, bell-bottom style", + }, + "连衣裙": { + "连衣短裙": "a short mini dress above the knee, one-piece garment", + "连衣长裙": "a long maxi dress reaching the ankles, elegant flowing one-piece", + "吊带裙": "a slip dress with thin spaghetti straps, sleeveless one-piece dress", + "衬衫连衣裙": "a shirt dress with button-up front and collar, shirt style one-piece dress", + "针织连衣裙": "a knitted one-piece dress with ribbed sweater texture, body-hugging or flowing knit", + "雪纺连衣裙": "a chiffon dress with sheer lightweight flowing layered fabric, romantic airy look", + "西装连衣裙": "a tailored blazer-style dress with lapels, structured office dress", + }, + "外套": { + "夹克": "a short lightweight jacket ending at waist or hip, sporty casual outerwear with zipper", + "西装外套": "a formal blazer or suit jacket with lapels, structured shoulders, button closure", + "风衣": "a long trench coat with belt, double-breasted buttons, knee-length waterproof style", + "羽绒服": "a puffy down jacket with quilted sections, thick warm insulation, winter puffer coat", + "大衣": "a long wool overcoat reaching knee or below, elegant warm winter coat", + "马甲": "a sleeveless vest worn over other clothes, no sleeves, front zipper or buttons", + "牛仔外套": "a denim jacket with metal buttons and chest flap pockets, blue jean fabric, trucker style", + "皮衣": "a leather jacket with glossy or matte leather surface, biker or fashion style", + "棉服": "a quilted cotton padded jacket, lightweight puffer, insulated casual winter jacket", + }, + "鞋": { + "运动鞋": "a pair of athletic sneakers with rubber sole, sport running or training shoes", + "皮鞋": "a pair of formal leather dress shoes with polished shiny surface, lace-up or slip-on", + "凉鞋": "a pair of open-toe sandals with straps, exposed toes, summer footwear", + "靴子": "a pair of boots covering ankle or higher, sturdy footwear with high-top shaft", + "帆布鞋": "a pair of canvas shoes with flat rubber sole, casual lace-up sneakers", + "高跟鞋": "a pair of high heel shoes or pumps with elevated heel, formal feminine footwear", + "乐福鞋": "a pair of loafers, slip-on leather shoes without laces, flat casual dress shoes", + "外穿拖鞋": "a pair of outdoor slides or chunky slippers, open-toe flat sandals for casual wear", + "玛丽珍鞋": "a pair of Mary Jane shoes with rounded toe and one or more straps across the instep, feminine flat or slight heel", + "洞洞鞋": "a pair of foam clogs with perforated holes on the upper, casual slip-on crocs style", + "马丁靴": "a pair of combat boots with chunky sole, lace-up leather ankle boots", + }, + "配饰": { + "帽子": "a hat or cap worn on the head, baseball cap, beanie, bucket hat or sun hat", + "围巾": "a scarf worn around the neck, long fabric accessory", + "腰带": "a belt worn around the waist, leather strap with metal buckle", + "墨镜": "a pair of sunglasses with dark tinted lenses", + "项链": "a necklace, jewelry chain or pendant worn around the neck", + "手链": "a bracelet or bangle, purely decorative jewelry worn on the wrist, no watch face, no dial, no clock", + "包包": "a handbag, purse, shoulder bag, tote bag or backpack accessory", + "耳饰": "a pair of earrings, jewelry worn on the earlobes", + "丝巾": "a silk scarf or bandana, lightweight decorative fabric neck accessory", + "发带": "a headband or hair tie accessory worn on the head or in the hair", + "戒指": "a ring, small jewelry worn on the finger", + "胸针": "a brooch, decorative pin jewelry attached to clothing", + "手表": "a wristwatch timepiece with a large prominent round or square watch face at the center, a clock dial showing numbers and watch hands or digital display, buttons or crown on the sides, thick strap band, worn on the wrist as a timekeeping device", + }, +} + +COLOR_PROMPTS = { + "黑色": "a black colored garment, very dark black clothing", + "白色": "a white colored garment, pure white clothing", + "红色": "a red colored garment, bright red clothing", + "蓝色": "a standard blue colored garment, medium blue clothing", + "绿色": "a green colored garment, green clothing", + "黄色": "a yellow colored garment, bright yellow clothing", + "粉色": "a pink colored garment, soft pink clothing", + "紫色": "a purple colored garment, violet purple clothing", + "灰色": "a gray colored garment, grey clothing", + "棕色": "a brown colored garment, brown clothing", + "橙色": "an orange colored garment, orange clothing", + "卡其色": "a khaki colored garment, beige-tan clothing", + "米色": "a cream or beige colored garment, off-white soft beige clothing", + "杏色": "an apricot colored garment, soft peachy beige clothing", + "焦糖色": "a caramel brown colored garment, warm rich toffee brown clothing", + "藏青色": "a navy blue colored garment, very dark deep blue clothing", + "天蓝色": "a sky blue colored garment, light bright clear blue clothing", + "军绿色": "an olive green or army green colored garment, military green clothing", + "酒红色": "a burgundy or wine red colored garment, deep maroon red clothing", + "金色": "a gold colored garment, metallic golden shiny clothing", + "银色": "a silver colored garment, metallic silver shiny clothing", + "玫红色": "a rose red or hot pink magenta colored garment, vivid pinkish red clothing", + "荧光色": "a neon fluorescent colored garment, extremely bright glowing highlighter colors like neon green or neon orange", + "墨绿色": "a dark forest green colored garment, deep dark green clothing", + "宝蓝色": "a royal blue colored garment, saturated vivid bright blue clothing", + "浅蓝色": "a light blue colored garment, pale soft pastel blue clothing", + "粉紫色": "a lavender or lilac colored garment, soft purple-pink pastel clothing", + "驼色": "a camel colored garment, warm tan brown beige clothing", + "砖红色": "a brick red colored garment, muted earthy red-brown clothing", + "浅灰色": "a light gray colored garment, pale soft grey clothing", +} + +STYLE_PROMPTS = { + "休闲风": "casual everyday style clothing, relaxed comfortable fashion", + "通勤风": "business formal office style clothing, professional workplace attire", + "运动风": "athletic sporty style clothing, gym and workout fashion", + "街头风": "streetwear urban style clothing, trendy hip-hop influenced fashion", + "优雅风": "elegant sophisticated style clothing, feminine chic fashion", + "甜美风": "cute sweet girly style clothing, lovely feminine fashion with ruffles and pastel colors", + "复古风": "vintage retro style clothing, old-school classic fashion from past decades", + "简约风": "minimalist simple style clothing, clean basic design without decoration", + "商务正装风": "formal business suit style clothing, corporate professional tailored attire", + "轻熟风": "smart casual mature style clothing, polished sophisticated young professional fashion", + "工装风": "utility workwear style clothing, cargo pockets and durable functional fashion", + "学院风": "preppy academic style clothing, college campus style with plaid, knit sweaters, collared shirts", + "甜酷风": "sweet and edgy style clothing, feminine meets punk, cute yet rebellious fashion", + "辣妹风": "sexy hot girl style clothing, bold bodycon and crop top fashion, confident glam look", + "度假风": "resort vacation style clothing, tropical holiday beachwear, relaxed bohemian fashion", + "新中式风": "modern Chinese style clothing, contemporary oriental fashion with traditional Chinese elements", + "极简风": "extreme minimalist style clothing, monochrome neutral colors, no logos no patterns, pure basic design", + "波西米亚风": "bohemian style clothing, flowy fabric, fringe, ethnic embroidery, relaxed free-spirited silhouette", + "朋克风": "punk style clothing, black leather, studs, ripped details, rebellious rock look", + "嘻哈风": "hip-hop style clothing, oversized baggy silhouette, urban street rap culture fashion", + "森系风": "mori girl forest style clothing, natural earthy colors, layered loose linen and cotton, rustic gentle look", + "法式风": "French chic style clothing, effortless elegant parisian look, simple refined feminine fashion", + "美式复古风": "American vintage retro style clothing, classic 90s american casual, denim, varsity, old-school usa look", + "日系风": "Japanese style clothing, clean simple japanese fashion, relaxed natural fabric look", + "韩系风": "Korean style clothing, trendy korean fashion, soft colors, neat youthful casual look", +} + +FIT_PROMPTS = { + "宽松": "a loose oversized fit garment, baggy relaxed silhouette clothing", + "修身": "a slim fitted garment, tailored silhouette that follows the body shape", + "阔腿": "a wide leg loose flowing silhouette, palazzo or flare pants style", + "紧身": "a tight skinny fit garment, bodycon stretch clothing, form-fitting second skin", + "常规合身": "a regular standard fit garment, normal classic cut, neither tight nor loose", + "直筒": "a straight leg or straight cut garment, parallel vertical lines from top to bottom, no flare no taper", +} + +MATERIAL_PROMPTS = { + "牛仔布": "denim fabric material, rugged blue cotton twill with visible diagonal weave and stitching", + "针织": "knitted fabric material, textured knitwear with visible yarn loops, sweater material", + "皮质": "genuine leather material, smooth shiny or matte animal hide surface with natural grain", + "纯棉": "pure cotton material, soft matte natural cotton fabric, breathable woven cotton textile", + "羊毛": "wool material, fuzzy warm woolen fabric, thick natural animal fiber textile", + "丝绸": "silk material, smooth lustrous shiny satin-like fabric, elegant silky sheen surface", + "雪纺": "chiffon material, sheer lightweight translucent fabric, soft delicate flowy textile", + "麻料": "linen material, textured natural flax fabric with visible weave, breathable rustic look", + "蕾丝": "lace material, delicate openwork patterned fabric, intricate floral mesh textile", + "尼龙": "nylon synthetic material, smooth shiny technical fabric, waterproof windbreaker material", + "灯芯绒": "corduroy material, ribbed velvety fabric with parallel raised cords, textured wales", + "天鹅绒": "velvet material, soft plush fabric with rich deep sheen and smooth nap", + "缎面": "satin material, smooth glossy surface with strong silky shine, fluid drape", + "网纱": "tulle or mesh material, sheer fine net fabric, transparent gauzy texture", + "羊羔绒": "sherpa fleece material, fluffy curly wool-like lining or surface, cozy plush texture", + "PU皮": "faux leather PU material, artificial leather with uniform smooth plastic-like glossy surface", + "磨毛棉": "brushed cotton material, soft slightly fuzzy matte surface, cozy flannel-like texture", +} + +PATTERN_PROMPTS = { + "纯色": "a solid color garment with no pattern, plain single-color fabric without any design", + "条纹": "a striped pattern garment with horizontal or vertical parallel lines", + "格纹": "a plaid or checkered pattern garment, crossed horizontal and vertical lines forming squares", + "印花": "a printed pattern garment with graphic floral or artistic designs on fabric", + "刺绣": "an embroidered garment with raised stitched thread decorative patterns", + "波点": "a polka dot pattern garment with small round dots evenly distributed", + "迷彩": "a camouflage pattern garment with irregular green brown patches, military style", + "扎染": "a tie-dye pattern garment with irregular colorful swirled dyed patterns", + "碎花": "a small floral print pattern garment with tiny scattered flower designs", + "字母": "a lettering or slogan print garment with text words or logos", + "千鸟格": "a houndstooth check pattern garment, small broken zigzag checks in two-tone", + "菱格纹": "an argyle pattern garment with diamond shapes, diagonal intersecting lines", + "人字纹": "a herringbone pattern garment with V-shaped zigzag weave, broken twill", + "拼色块": "a color block garment with contrasting solid color panels stitched together in large sections", + "渐变": "an ombre gradient pattern garment with colors fading gradually from one shade to another", + "豹纹": "a leopard print pattern garment with animal spots, brown and black rosette spots", + "蕾丝花纹": "a lace pattern garment with delicate openwork floral mesh design", + "几何图案": "a geometric pattern garment with shapes like triangles, circles and abstract lines arranged in order", + "logo图案": "a logo or brand emblem print garment with large recognizable brand marks or icons", +} + +# 厚度(适用:上装/下装/连衣裙/外套/鞋) +THICKNESS_PROMPTS = { + "薄": "a thin lightweight fabric garment, sheer or semi-sheer summer clothing, cool breathable material", + "适中": "a medium weight fabric garment, regular thickness clothing, standard everyday material", + "厚": "a thick heavy fabric garment, warm dense material, substantial winter clothing", + "加厚": "an extra thick heavily padded garment, quilted puffy down insulation, maximum warmth winter wear", +} + +# 袖型(适用:上装/连衣裙/外套) +SLEEVE_TYPE_PROMPTS = { + "无袖": "a sleeveless garment with no sleeves, bare shoulders and arms exposed", + "吊带": "a garment with thin spaghetti straps, narrow shoulder straps instead of sleeves", + "短袖": "a short sleeve garment, sleeves ending above the elbow", + "中袖": "a half sleeve or elbow length sleeve garment, sleeves ending at or below the elbow", + "长袖": "a long sleeve garment, sleeves reaching the wrist", + "喇叭袖": "a flared bell sleeve garment, sleeve widening dramatically from elbow to a wide cuff", + "泡泡袖": "a puff sleeve garment, voluminous balloon sleeve gathered at shoulder and cuff", + "落肩袖": "a dropped shoulder sleeve garment, shoulder seam falling below the natural shoulder, relaxed slouchy look", +} + +# 衣长类型(适用:上装/下装/连衣裙/外套) +CLOTHES_TYPE_PROMPTS = { + "短款": "a cropped short length garment, ends above or at the waist, showing midriff or waistline", + "常规": "a regular standard length garment, normal classic length, ends around hip or upper thigh", + "中长": "a mid length garment, ends around mid-thigh or knee", + "长款": "a long length garment, ends below the knee or at the calf", + "超长款": "an extra long garment, ankle-length or floor-length, maxi style full length", +} + +# 领型(适用:上装/连衣裙/外套) +NECKLINE_PROMPTS = { + "圆领": "a round crew neckline, circular collar hugging the neck", + "V领": "a V-shaped neckline, pointed dip at the front center chest", + "方领": "a square neckline with straight horizontal edge across the chest", + "高领": "a turtleneck high collar, folded fabric covering the whole neck", + "翻领": "a lapel or shirt collar, folded flat collar with pointed tips", + "立领": "a standing band collar, mandarin collar rising vertically around the neck", + "一字领": "an off-shoulder neckline, straight horizontal top edge exposing the shoulders", + "挂脖": "a halter neckline with straps tying behind the neck, open back and shoulders", + "连帽": "a hooded garment with an attached hood around the neckline", + "娃娃领": "a peter pan collar, small flat rounded collar lying on the garment", +} + +# 正式程度 1-5(label 分类后映射为数字等级) +FORMALITY_PROMPTS = { + "非常休闲": "very casual clothing, sporty gym wear, beach wear, loungewear, relaxed informal style", + "休闲": "casual clothing, everyday relaxed wear, jeans and t-shirt style", + "轻正式": "smart casual clothing, neat polished everyday style, business casual attire", + "商务正式": "business formal clothing, office suit and tailored professional attire", + "非常正式": "very formal clothing, evening gown, tuxedo, ceremonial dress code attire", +} + +# 适用场合(取置信度达标的 top-N 逗号连接) +OCCASION_PROMPTS = { + "日常通勤": "everyday commuting and office wear, practical comfortable work outfit", + "运动健身": "sports and gym workout wear, athletic exercise clothing", + "约会聚会": "date night and social gathering outfit, romantic or fashionable party look", + "正式商务": "formal business meeting attire, professional corporate dress code", + "居家休闲": "home loungewear, cozy at-home relaxing clothing", + "度假旅游": "vacation and travel wear, resort holiday clothing, beach and sightseeing outfit", + "户外活动": "outdoor activity wear, hiking camping adventure functional clothing", + "派对夜店": "party and nightclub outfit, trendy bold nightlife fashion", + "校园上课": "school and campus wear, youthful student daily outfit", + "婚礼宴会": "wedding and banquet attire, elegant formal celebration dress", +} + +# 视觉冲击力 1-5(label 分类后映射为数字等级) +VISUAL_IMPACT_PROMPTS = { + "低调": "very low visual impact, plain minimal clothing, muted neutral colors, no decoration", + "平淡": "low visual impact, simple understated clothing, basic design with little attention-grabbing", + "适中": "moderate visual impact, average clothing with normal color and simple design details", + "亮眼": "high visual impact, eye-catching clothing, bright colors or striking design, stands out", + "高调": "very high visual impact, bold dazzling clothing, neon glitter dramatic statement design, impossible to ignore", +} + +# ============================================================================ +# 批量推理分组:所有维度 + 子类别按大类展开,一次前向全部完成 +# ============================================================================ +INFERENCE_GROUPS = { + "category": CATEGORY_PROMPTS, + "color": COLOR_PROMPTS, + "style": STYLE_PROMPTS, + "version": FIT_PROMPTS, + "material": MATERIAL_PROMPTS, + "pattern": PATTERN_PROMPTS, + "thickness": THICKNESS_PROMPTS, + "sleeve_type": SLEEVE_TYPE_PROMPTS, + "clothes_type": CLOTHES_TYPE_PROMPTS, + "neckline_type": NECKLINE_PROMPTS, + "formality": FORMALITY_PROMPTS, + "occasion": OCCASION_PROMPTS, + "visual_impact": VISUAL_IMPACT_PROMPTS, +} +for _main, _subs in SUBCATEGORY_GROUPS.items(): + INFERENCE_GROUPS[f"subcategory:{_main}"] = _subs + +# 副颜色提取阈值:第二颜色置信度达到 min(top1 * 40%, 0.15) 才输出 +COLOR_SUB_RATIO_THRESHOLD = 0.40 +COLOR_SUB_ABS_THRESHOLD = 0.15 + +# 适用场合:置信度不低于 top1 的 60%,最多取 3 个 +OCCASION_MIN_RATIO = 0.60 +OCCASION_MAX_COUNT = 3 + +# 维度适用性:各维度仅在指定大类下输出,其余品类置空 +DIMENSION_APPLICABILITY = { + "thickness": {"上装", "下装", "连衣裙", "外套", "鞋"}, + "version": {"上装", "下装", "连衣裙", "外套"}, + "sleeve_type": {"上装", "连衣裙", "外套"}, + "clothes_type": {"上装", "下装", "连衣裙", "外套"}, + "neckline_type": {"上装", "连衣裙", "外套"}, +} + +# 1-5 等级映射(中文标签 -> 数字等级字符串) +FORMALITY_LEVELS = { + "非常休闲": "1", + "休闲": "2", + "轻正式": "3", + "商务正式": "4", + "非常正式": "5", +} + +VISUAL_IMPACT_LEVELS = { + "低调": "1", + "平淡": "2", + "适中": "3", + "亮眼": "4", + "高调": "5", +} + + +def _classify_all( + model: CLIPModel, + processor: CLIPProcessor, + image: Image.Image, + groups: dict, + device: str, +) -> dict: + """一次前向完成多组分类。 + + groups: 组名 -> {中文标签: 英文提示词} + 返回: 组名 -> [{"label": 标签, "confidence": 置信度}, ...] 按置信度降序 + """ + texts = [] + boundaries = [] + for prompts in groups.values(): + start = len(texts) + texts.extend(prompts.values()) + boundaries.append((start, len(texts))) + + inputs = processor( + text=texts, + images=image, + return_tensors="pt", + padding=True, + ).to(device) + + with torch.inference_mode(): + outputs = model(**inputs) + logits = outputs.logits_per_image # [1, total_texts] + logits = logits.squeeze(0).cpu() + + results = {} + for name, (start, end) in zip(groups.keys(), boundaries): + labels = list(groups[name].keys()) + probs = torch.softmax(logits[start:end], dim=-1) + order = torch.argsort(probs, descending=True) + results[name] = [ + {"label": labels[int(i)], "confidence": round(float(probs[int(i)]), 4)} + for i in order + ] + return results + + +def _top1(results: list) -> tuple: + """取组内 top1 的 (标签, 置信度)。""" + return results[0]["label"], results[0]["confidence"] + + +def _extract_color_sub(color_results: list) -> tuple: + """从颜色 top-2 提取副颜色,置信度过低则返回空。""" + if len(color_results) < 2: + return "", 0.0 + top_conf = color_results[0]["confidence"] + sub = color_results[1] + threshold = min(top_conf * COLOR_SUB_RATIO_THRESHOLD, COLOR_SUB_ABS_THRESHOLD) + if sub["confidence"] >= threshold: + return sub["label"], sub["confidence"] + return "", 0.0 + + +def _pick_occasions(occasion_results: list) -> str: + """取置信度不低于 top1 一定比例的场合,逗号连接。""" + top_conf = occasion_results[0]["confidence"] + picked = [ + r["label"] + for r in occasion_results + if r["confidence"] >= top_conf * OCCASION_MIN_RATIO + ][:OCCASION_MAX_COUNT] + if not picked: + picked = [occasion_results[0]["label"]] + return ",".join(picked) + + +# ---------- 模型加载 ---------- +MODEL_NAME = "patrickjohncyh/fashion-clip" +device = "cuda" if torch.cuda.is_available() else "cpu" + +# 模型缓存走 HuggingFace 默认目录(~/.cache/huggingface) +logger.info(f"Loading {MODEL_NAME} on {device} ...") +try: + model = CLIPModel.from_pretrained(MODEL_NAME, local_files_only=True) + processor = CLIPProcessor.from_pretrained(MODEL_NAME, local_files_only=True) + logger.info(f"{MODEL_NAME} 命中本地缓存,离线加载完成") +except (OSError, ValueError) as e: + logger.info(f"本地未找到 {MODEL_NAME} 缓存,自动从 HuggingFace 下载 ... ({e})") + model = CLIPModel.from_pretrained(MODEL_NAME) + processor = CLIPProcessor.from_pretrained(MODEL_NAME) + logger.info(f"{MODEL_NAME} 下载完成") + +model = model.to(device) +model.eval() + +if device == "cuda": + model.half() + +logger.info(f"Fashion-CLIP loaded on {device}") + + +@app.post("/classify") +async def classify(file: UploadFile): + """对上传的衣物图片进行分类,返回与 clothes 表模型对齐的 15 个维度。 + + Response: + category_main: 衣物大类(上装/下装/连衣裙/外套/鞋/配饰) + category_sub: 子类别(大类对应组内 top1) + style: 风格 + color_main: 主颜色 + color_sub: 副颜色(top-2 置信度达标才输出,否则空字符串) + thickness: 厚度(薄/适中/厚/加厚) + version: 版型(宽松/修身/阔腿/紧身/常规合身/直筒) + material: 材质 + pattern: 花纹 + sleeve_type: 袖型(仅上装/连衣裙/外套,其余置空) + clothes_type: 衣长类型(短款/常规/中长/长款/超长款) + neckline_type: 领型(仅上装/连衣裙/外套,其余置空) + formality: 正式程度 "1"~"5"(附 formality_label 中文标签) + occasions: 适用场合(top-N 逗号连接) + visual_impact: 视觉冲击力 "1"~"5"(附 visual_impact_label 中文标签) + details: 各维度 Top-5 明细 + """ + raw = await file.read() + try: + image = Image.open(io.BytesIO(raw)) + # 仅含透明通道的图片(PNG 等)合成白底,避免透明通道干扰 CLIP;普通 JPEG 直接转 RGB + if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info): + image = image.convert("RGBA") + bg = Image.new("RGBA", image.size, (255, 255, 255, 255)) + image = Image.alpha_composite(bg, image) + image = image.convert("RGB") + except Exception: + raise HTTPException(status_code=400, detail="invalid image") + + # 1. 一次前向完成全部分类 + results = _classify_all(model, processor, image, INFERENCE_GROUPS, device) + + # 2. 大类 + best_main, best_main_conf = _top1(results["category"]) + + # 3. 子类别:大类对应组内 top1 + sub_results = results.get(f"subcategory:{best_main}", []) + if sub_results: + best_sub, best_sub_conf = _top1(sub_results) + else: + best_sub, best_sub_conf = "", 0.0 + + # 4. 颜色:主色 + 副色(top-2 阈值提取) + color_results = results["color"] + best_color, best_color_conf = _top1(color_results) + best_color_sub, best_color_sub_conf = _extract_color_sub(color_results) + + # 5. 风格 / 材质 / 花纹 + best_style, best_style_conf = _top1(results["style"]) + best_material, best_material_conf = _top1(results["material"]) + best_pattern, best_pattern_conf = _top1(results["pattern"]) + + # 6. 适用性控制:不适用维度置空 + def pick_or_empty(dim: str) -> tuple: + if best_main in DIMENSION_APPLICABILITY.get(dim, set()): + return _top1(results[dim]) + return "", 0.0 + + best_thickness, best_thickness_conf = pick_or_empty("thickness") + best_version, best_version_conf = pick_or_empty("version") + best_sleeve, best_sleeve_conf = pick_or_empty("sleeve_type") + best_clothes_type, best_clothes_type_conf = pick_or_empty("clothes_type") + best_neckline, best_neckline_conf = pick_or_empty("neckline_type") + + # 7. 正式程度 1-5 + formality_label, formality_conf = _top1(results["formality"]) + formality = FORMALITY_LEVELS.get(formality_label, "") + + # 8. 适用场合 top-N + occasions = _pick_occasions(results["occasion"]) + + # 9. 视觉冲击力 1-5 + impact_label, impact_conf = _top1(results["visual_impact"]) + impact = VISUAL_IMPACT_LEVELS.get(impact_label, "") + + def detail(name: str) -> list: + return results.get(name, [])[:5] + + return { + "category_main": best_main, + "category_main_confidence": best_main_conf, + "category_sub": best_sub, + "category_sub_confidence": best_sub_conf, + "style": best_style, + "style_confidence": best_style_conf, + "color_main": best_color, + "color_main_confidence": best_color_conf, + "color_sub": best_color_sub, + "color_sub_confidence": best_color_sub_conf, + "thickness": best_thickness, + "thickness_confidence": best_thickness_conf, + "version": best_version, + "version_confidence": best_version_conf, + "material": best_material, + "material_confidence": best_material_conf, + "pattern": best_pattern, + "pattern_confidence": best_pattern_conf, + "sleeve_type": best_sleeve, + "sleeve_type_confidence": best_sleeve_conf, + "clothes_type": best_clothes_type, + "clothes_type_confidence": best_clothes_type_conf, + "neckline_type": best_neckline, + "neckline_type_confidence": best_neckline_conf, + "formality": formality, + "formality_label": formality_label, + "formality_confidence": formality_conf, + "occasions": occasions, + "visual_impact": impact, + "visual_impact_label": impact_label, + "visual_impact_confidence": impact_conf, + "details": { + "category_main": detail("category"), + "category_sub": sub_results[:5], + "style": detail("style"), + "color_main": color_results[:5], + "thickness": detail("thickness"), + "version": detail("version"), + "material": detail("material"), + "pattern": detail("pattern"), + "sleeve_type": detail("sleeve_type"), + "clothes_type": detail("clothes_type"), + "neckline_type": detail("neckline_type"), + "formality": detail("formality"), + "occasions": detail("occasion"), + "visual_impact": detail("visual_impact"), + }, + } + + +@app.get("/healthz") +def healthz(): + return {"status": "ok"} + + +if __name__ == "__main__": + # 模型已在模块 import 时于模块级加载,这里直接传 app 对象启动, + # 不要用 "main:app" 字符串 + reload 方式,否则会重复加载模型。 + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/thirdparty/classification_server/requirements.txt b/thirdparty/classification_server/requirements.txt new file mode 100644 index 0000000..f7349d6 --- /dev/null +++ b/thirdparty/classification_server/requirements.txt @@ -0,0 +1,16 @@ +# ============================================================================ +# Fashion-CLIP 衣物分类服务依赖清单 +# 安装:pip install -r requirements.txt +# +# 注意:PyPI 默认安装的是 CPU 版 torch。 +# 如果你的机器有 NVIDIA GPU,请先按 PyTorch 官网命令单独安装对应 CUDA 版 torch, +# 再执行 pip install -r requirements.txt(已安装的 torch 会被跳过): +# pip install torch --index-url https://download.pytorch.org/whl/cu121 +# ============================================================================ + +torch>=2.0.0 +transformers>=4.30.0 +fastapi>=0.100.0 +uvicorn>=0.23.0 +pillow>=9.5.0 +python-multipart>=0.0.9 # FastAPI 处理 multipart 表单(/classify 上传图片)必需 diff --git a/thirdparty/matting_server/main.py b/thirdparty/matting_server/main.py new file mode 100644 index 0000000..8bba5ee --- /dev/null +++ b/thirdparty/matting_server/main.py @@ -0,0 +1,96 @@ +import io +import logging +import os + +# !!!必须在 import transformers 之前设置,否则 huggingface_hub 读不到这个环境变量!!! +if "HF_ENDPOINT" not in os.environ: + os.environ["HF_ENDPOINT"] = "https://hf-mirror.com" + +import torch +from fastapi import FastAPI, File, HTTPException, UploadFile +from fastapi.responses import Response +from PIL import Image +from torchvision import transforms +from transformers import AutoModelForImageSegmentation + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="BiRefNet Matting Service") + +# 模块加载时就把模型读进内存/显存,常驻复用(BiRefNet_lite = small 变体) +# 模型缓存走 HuggingFace 默认目录(~/.cache/huggingface):本地存在则离线加载,不存在则自动下载 +# 可通过环境变量 BIREFNET_REPO_ID 覆盖模型来源(本地目录或 HuggingFace repo id), +# 默认使用 BiRefNet_lite(small 变体)。 +_REPO_ID = os.environ.get("BIREFNET_REPO_ID", "ZhengPeng7/BiRefNet_lite") + +try: + model = AutoModelForImageSegmentation.from_pretrained( + _REPO_ID, + trust_remote_code=True, + local_files_only=True, + ) + logger.info("BiRefNet 命中本地缓存,离线加载完成") +except (OSError, ValueError) as e: + logger.info(f"本地未找到 BiRefNet 模型缓存,自动从 HuggingFace 下载 ... ({e})") + model = AutoModelForImageSegmentation.from_pretrained( + _REPO_ID, + trust_remote_code=True, + ) + logger.info("BiRefNet 模型下载完成") +model.eval() +device = "cuda" if torch.cuda.is_available() else "cpu" +if device == "cuda": + model.to(device) + model.half() # GPU 半精度省显存 +else: + # 模型权重可能为 fp16(如 ModelScope 镜像),CPU 推理统一转 fp32 避免 dtype 不匹配 + model.to(device).float() + +transform = transforms.Compose( + [ + transforms.Resize((1024, 1024)), + transforms.ToTensor(), + transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), + ] +) + + +@app.post("/matting") +async def matting(file: UploadFile = File(...)): + raw = await file.read() + try: + image = Image.open(io.BytesIO(raw)).convert("RGB") + except Exception: + raise HTTPException(status_code=400, detail="invalid image") + + w, h = image.size + inp = transform(image).unsqueeze(0).to(device) + if device == "cuda": + inp = inp.half() + + with torch.no_grad(): + preds = model(inp)[-1].sigmoid().cpu() + + # 单通道概率图 → resize 回原尺寸 → 作为 alpha 合成透明底 PNG + mask = transforms.ToPILImage()(preds[0].squeeze()).resize((w, h)) + result = image.convert("RGBA") + result.putalpha(mask) + + buf = io.BytesIO() + result.save(buf, format="PNG") + return Response(content=buf.getvalue(), media_type="image/png") + + +@app.get("/healthz") +def healthz(): + # 能响应即代表模型已加载完成(模块级加载在 import 时完成) + return {"status": "ok"} + + +if __name__ == "__main__": + # 模型已在模块 import 时于模块级加载,这里直接传 app 对象启动, + # 不要用 "main:app" 字符串 + reload 方式,否则会重复加载模型。 + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/thirdparty/matting_server/requirements.txt b/thirdparty/matting_server/requirements.txt new file mode 100644 index 0000000..a007504 --- /dev/null +++ b/thirdparty/matting_server/requirements.txt @@ -0,0 +1,23 @@ +# ============================================================================ +# BiRefNet 抠图服务依赖清单 +# 安装:pip install -r requirements.txt +# +# 注意 1:PyPI 默认安装的是 CPU 版 torch。 +# 如果你的机器有 NVIDIA GPU,请先按 PyTorch 官网命令单独安装对应 CUDA 版 torch, +# 再执行 pip install -r requirements.txt(已安装的 torch/torchvision 会被跳过): +# pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 +# +# 注意 2:模型会自动下载到 HuggingFace 默认缓存目录 ~/.cache/huggingface(走 HF_ENDPOINT 镜像)。 +# 首次启动若本地无缓存会联网下载 BiRefNet_lite;之后启动命中本地缓存则完全离线。 +# ============================================================================ + +torch>=2.0.0 +torchvision>=0.15.0 +transformers>=4.44.0 +fastapi>=0.100.0 +uvicorn>=0.23.0 +pillow>=9.5.0 +python-multipart>=0.0.9 # FastAPI 处理 multipart 表单(UploadFile)必需 +einops>=0.7.0 # BiRefNet 自定义建模代码(birefnet.py)依赖 +kornia>=0.7.0 # BiRefNet 自定义建模代码(birefnet.py)依赖 +timm>=0.9.0 # BiRefNet 自定义建模代码(birefnet.py)依赖 diff --git a/接口文档.md b/接口文档.md new file mode 100644 index 0000000..038dd02 --- /dev/null +++ b/接口文档.md @@ -0,0 +1,2326 @@ +# 智能衣橱(zt-closet)后端接口文档 · 前端版 + +> 面向**前端/小程序工程师**的联调文档。全文基于后端源码(`internal/routers`、`internal/dto`、`internal/service`、`pkg/errcode`)逐字段核对生成,包含接口入参出参、业务逻辑与状态机、调用时序、错误码全表。 +> +> - 服务:Go + Gin,微信小程序后端 +> - 统一前缀:`/api/v1`(下文中路径如 `/user/wxlogin` 均表示 `/api/v1/user/wxlogin`) +> - 健康检查:`GET /ping`(无 `/api/v1` 前缀) +> - 文档版本:v1.1 · 生成日期:2026-09-09 + +--- + +## 目录 + +- [1. 五分钟上手](#1-五分钟上手) +- [2. 通用调用约定](#2-通用调用约定) + - [2.1 鉴权与 Token](#21-鉴权与-token) + - [2.2 统一响应结构](#22-统一响应结构) + - [2.3 HTTP 状态码与业务码](#23-http-状态码与业务码) + - [2.4 分页约定](#24-分页约定) + - [2.5 单位约定](#25-单位约定) + - [2.6 幂等与重试](#26-幂等与重试) + - [2.7 需要轮询的场景](#27-需要轮询的场景) + - [2.8 CORS 与链路追踪](#28-cors-与链路追踪) +- [3. 接口总表(43 个)](#3-接口总表43-个) +- [4. 公共数据结构](#4-公共数据结构) +- [5. 接口详解](#5-接口详解) + - [5.1 用户模块](#51-用户模块) + - [5.2 额度模块](#52-额度模块) + - [5.3 运势模块](#53-运势模块) + - [5.4 衣物模块](#54-衣物模块) + - [5.5 穿搭模块](#55-穿搭模块) + - [5.6 AI 推荐模块](#56-ai-推荐模块) + - [5.7 衣柜分享模块](#57-衣柜分享模块) + - [5.8 收藏与点赞模块](#58-收藏与点赞模块) + - [5.9 通知模块](#59-通知模块) + - [5.10 商品模块](#510-商品模块) + - [5.11 订单与退款模块](#511-订单与退款模块) +- [6. 业务逻辑与状态机](#6-业务逻辑与状态机) +- [7. 核心调用时序图](#7-核心调用时序图) +- [8. 错误码全表](#8-错误码全表) +- [9. 前端联调 FAQ 与检查清单](#9-前端联调-faq-与检查清单) +- [10. 接口调用示例(curl)](#10-接口调用示例curl) + +--- + +## 1. 五分钟上手 + +标准联调路径: + +```text +1. wx.login() 拿到 code + └─> POST /user/wxlogin -> access_token / refresh_token / user_info +2. 所有需登录接口带 Header: + Authorization: Bearer +3. 上传衣物 + POST /clothes/upload (multipart, 字段名 file) + └─> { clothes_uuid, image_process_status: 3 } + └─> 轮询 GET /clothes/:uuid/status 直到 4(成功)或 5(失败) +4. 做穿搭 + POST /outfit 创建 -> PUT /outfit/:uuid/public 公开(扣额度,进入社区) + 或 POST /ai-recommend AI 推荐 -> POST /ai-recommend/save 保存 +5. 额度不够 + GET /goods 商品列表 -> POST /order 下单 -> wx.requestPayment(pay_params) + -> 轮询 GET /order 直到 order_status = 2(已支付) +``` + +**最容易踩的 6 个坑(详见 [第 9 节](#9-前端联调-faq-与检查清单)):** + +1. **判断成败只看 body 里的 `code`**,绝大多数业务错误返回 HTTP 200(包括 `21001 账号已被禁用`)。 +2. **刷新 Token 必须同时传 `refresh_token` 和 `access_token`**,否则报 `10000`;成功后旧的两个 token 立即失效,必须整体替换。 +3. **衣物上传是异步的**,返回只代表"已排队",必须轮询状态接口。 +4. **更新衣物的主颜色请求字段是 `color`**,响应里才是 `color_main`。 +5. **金额单位都是分、时间都是秒**,展示前要换算。 +6. **AI 推荐生成后不保存不退额度**;退款是人工审核,不是实时到账。 + +--- + +## 2. 通用调用约定 + +### 2.1 鉴权与 Token + +**请求头格式** + +```http +Authorization: Bearer +``` + +- 必须恰好是 `Bearer` + 空格 + token(大小写敏感),其它写法一律 `11001`。 +- access_token 默认有效期 **7200 秒**(配置 `JWT.AccessTokenExpire`),refresh_token 默认 **86400 秒**。 +- 登录响应里的 `expire_time` 是 access_token 的过期时间戳(秒),前端可据此提前刷新。 + +**服务端校验顺序(失败即中断)** + +| 顺序 | 校验 | 返回 | HTTP | +| --- | --- | --- | --- | +| 1 | 无 `Authorization` 头 | `11004` Token 缺失 | 401 | +| 2 | 格式不是 `Bearer xxx` | `11001` + `details:["invalid Authorization format"]` | 401 | +| 3 | access_token 已过期 | `11002` Token 已过期 | 401 | +| 4 | 拿 refresh_token 当 access 用 | `11001` + `details:["token type mismatch"]` | 401 | +| 5 | Redis 会话不存在(已登出/被顶替) | `11001` + `details:["token session invalid"]` | 401 | +| 6 | 会话 userID 与 token 不一致 | `11001` + `details:["token user mismatch"]` | 401 | +| 7 | 用户不存在 | `11001` + `details:["用户不存在"]` | 401 | +| 8 | 用户 `status != 1`(禁用/注销) | `21001` 账号已被禁用 | **200** | + +> ⚠️ 第 8 步是 **HTTP 200 + code 21001**,不是 401。拦截器里必须单独判断并跳转封禁提示页。 + +**刷新流程(前端必须实现)** + +```text +任意接口返回 code = 11002 / 11001(HTTP 401) + └─> POST /user/refresh-token { refresh_token, access_token } // 两个都要传 + ├─ 成功 -> 用返回的两个新 token **整体替换**本地存储,再重试原请求 + └─ 失败(11001/11002)-> 清空本地登录态,重新走 wx.login +``` + +- 刷新成功后,服务端会**立即吊销旧的 access_token 与 refresh_token**,前端不能沿用任何旧值。 +- refresh_token 也是**一次性旋转**的:每次刷新都会返回新的 refresh_token。 + +**公开接口(无需 Token)**:`/ping`、`/user/wxlogin`、`/user/refresh-token`、`/goods`、`/goods/:code`、`/order/pay/notify`、`/order/refund/notify`。 + +--- + +### 2.2 统一响应结构 + +**成功**(HTTP 200) + +```json +{ + "code": 0, + "msg": "成功", + "data": { } +} +``` + +- `code` 恒为 `0`,`msg` 恒为 `"成功"`。 +- **`data` 永不为 `null`**:无业务数据(删除、取消、标记已读等)时是空对象 `{}`。 +- 成功响应**没有 `details` 字段**。 + +**失败** + +```json +{ + "code": 80000, + "msg": "额度不足", + "details": ["额度不足,请先购买"] +} +``` + +- 失败响应**没有 `data` 字段**,前端不要读取 `data`。 +- `details` 是**可选**的字符串数组,仅在有补充信息时出现(参数校验错误、业务提示等)。典型内容: + `"请先完善生日信息"`、`"过期时间必须晚于当前时间"`、`"部分分享需选择衣物"`、`"衣橱暂无可用衣物"`、`"未生成有效搭配"`、`"缺少旧 access token"`、`"缺少微信 openid"` 等。 +- **`details` 可直接用于 toast 提示用户**,但建议仅对 `10000 参数错误` 与业务提示类展示,系统错误统一展示兜底文案。 + +--- + +### 2.3 HTTP 状态码与业务码 + +**绝大多数业务错误返回 HTTP 200**,只有下表这些会返回非 200: + +| HTTP | 业务 code | 场景 | +| --- | --- | --- | +| 200 | `0` 成功,**以及除下表外的所有业务码** | 成功与绝大多数业务错误 | +| **401** | `11000` 未授权、`11001` Token 鉴权失败、`11002` Token 已过期、`11004` Token 缺失 | 鉴权失败 → 走刷新或重新登录 | +| **403** | `12000` 无权限访问 | 越权操作他人资源 | +| **429** | `10003` 请求过于频繁 | 限流(如 AI 推荐并发锁),稍后重试 | +| **500** | `19000` 服务内部错误、`19001` 数据库错误、`19002` 缓存错误、`19003` 下游服务调用失败、`19999` 未知错误、`39001` 抠图失败、`39002` 分类失败 | 服务端异常 → 提示"服务繁忙" | + +> **前端拦截器建议**:先取 body 的 `code` 判断业务结果;`code` 缺失(网络层失败)时才回落到 HTTP 状态码。 + +--- + +### 2.4 分页约定 + +所有列表接口统一使用 **GET query 参数**: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| page | int | 否 | 页码,≥1,默认 **1** | +| page_size | int | 否 | 每页条数,1~50,默认 **10**;超过 50 服务端自动收敛为 50 | + +列表响应统一结构: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| total | int64 | 总条数 | +| page | int | 当前页码 | +| page_size | int | 每页条数(服务端实际生效值) | +| list | array | 数据数组;无数据时为空数组 `[]` | + +--- + +### 2.5 单位约定 + +| 类别 | 单位 | 涉及字段 | +| --- | --- | --- | +| **金额** | **分**(int64) | `price`、`discount_price`、`unit_price`、`pay_amount`、`refund_amount` | +| **时间** | **秒级 Unix 时间戳**(int64) | `expire_time`、`pay_time`、`refund_time`、`created_at`、`birth_day`、`vip_expire`、`ban_start`、`ban_expire`、`apply_time` | +| 日期 | `YYYYMMDD` 整数 | 运势 `stat_date` | + +> 展示金额需 `÷ 100`;展示时间需 `× 1000` 再交给 JS `Date`(因为 JS 用毫秒)。 + +--- + +### 2.6 幂等与重试 + +以下接口**天然幂等,可安全重试**(重复提交不会产生重复数据或重复扣费): + +- 点赞 / 取消点赞 `POST /like` +- 收藏 / 取消收藏 `POST /collect` +- 穿搭公开 `PUT /outfit/:uuid/public`(重复公开**不会**重复扣额度;但**取消公开后再次公开会再扣 1 次**,见 [6.6](#66-额度规则全景)) +- 标记已读 `POST /notification/:uuid/read` +- 保存 AI 推荐 `POST /ai-recommend/save` +- 取消订单 `POST /order/:orderNo/cancel` +- 取消分享 `POST /closet-share/:uuid/cancel` + +**不建议重试**:`POST /clothes/upload`(每次都会扣 1 个上传额度)、`POST /ai-recommend`(每次扣 1 个 AI 额度,且并发会触发 `10003`)、`POST /order`(会创建新订单)。 + +--- + +### 2.7 需要轮询的场景 + +后端**没有 WebSocket / 消息推送**,以下场景前端需主动轮询: + +| 场景 | 轮询接口 | 终态 | 建议间隔 | +| --- | --- | --- | --- | +| 衣物 AI 处理 | `GET /clothes/:uuid/status` | `4` 成功 / `5` 失败(`1/2/3` 继续轮询) | 1~2 秒,建议上限 60 次 | +| 支付结果 | `GET /order`(按 `status` 或 `order_no` 过滤) | `2` 已支付 / `3` 已取消 / `4` 已过期 | 支付后 1 秒起,最多 15 分钟 | +| 退款进度 | `GET /order` | `5` 已退款 | 30 秒(人工审核,可能很久) | +| 今日运势未生成 | `GET /horoscope/today` | 返回数据(首次可能 `90300`) | 3~5 秒,最多 3 次 | +| 未读通知数 | `GET /notification/unread` | 无终态,按需刷新 | 进入页面时 / 30~60 秒 | + +--- + +### 2.8 CORS 与链路追踪 + +- 允许的 Method:`GET, POST, PUT, PATCH, DELETE, OPTIONS`;允许的请求头:`Origin, Content-Type, Authorization`。 +- 响应头 `X-Request-ID` 可用于排障,但**未加入 CORS `Expose-Headers`,跨域场景下 JS 读不到**;需要排障时请在请求侧自带 `X-Request-ID` 头,服务端会透传。 +- 后端已开启 OpenTelemetry 全链路追踪,反馈问题时提供 `X-Request-ID` 可快速定位。 + +--- + +## 3. 接口总表(43 个) + +### 健康检查(1) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/ping` | 存活探针,返回 `{"message":"pong"}`,**不在 `/api/v1` 下** | + +### 公开路由(6,无需登录) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/user/wxlogin` | 微信登录(code 换 openid,首次自动注册) | +| POST | `/user/refresh-token` | 刷新 access_token | +| GET | `/goods` | 商品列表(仅上架) | +| GET | `/goods/:code` | 按商品编码查询 | +| POST | `/order/pay/notify` | 🚫 **微信支付回调,服务端专用,前端禁止调用** | +| POST | `/order/refund/notify` | 🚫 **微信退款回调,服务端专用,前端禁止调用** | +| GET/POST | `/xpay/notify` | 🚫 **虚拟支付消息推送(GET 为 URL 验证 / POST 为发货·退款·iOS 退款问询回调),服务端专用,前端禁止调用** | + +### 鉴权路由(36,需 `Authorization: Bearer`) + +**用户 / 额度 / 运势(5)** + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/user/info` | 当前用户信息 | +| PUT | `/user/profile` | 更新个人资料 | +| GET | `/quota/info` | 额度概览(4 类额度余量) | +| GET | `/quota/flow` | 额度流水 | +| GET | `/horoscope/today` | 今日星座运势(按生日推算星座) | + +**衣物(6)** + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/clothes/upload` | 上传衣物图片(扣上传额度,触发异步 AI 流水线) | +| GET | `/clothes` | 我的衣物列表 | +| GET | `/clothes/:uuid` | 衣物详情 | +| GET | `/clothes/:uuid/status` | 衣物 AI 处理状态(轮询用) | +| PUT | `/clothes/:uuid` | 更新衣物属性 | +| DELETE | `/clothes/:uuid` | 删除衣物(满足条件时回退上传额度) | + +**穿搭(6)** + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/outfit` | 创建穿搭 | +| GET | `/outfit` | 我的穿搭列表 | +| GET | `/outfit/:uuid` | 穿搭详情 | +| PUT | `/outfit/:uuid` | 更新穿搭 | +| DELETE | `/outfit/:uuid` | 删除穿搭 | +| PUT | `/outfit/:uuid/public` | 公开 / 取消公开(公开扣搭配分享额度) | + +**AI 推荐(2)** + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/ai-recommend` | 生成 AI 穿搭推荐(扣 AI 额度) | +| POST | `/ai-recommend/save` | 保存 AI 推荐为我的穿搭 | + +**衣柜分享(5)** + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/closet-share` | 创建衣柜分享(扣衣柜分享额度) | +| GET | `/closet-share` | 我的分享列表 | +| GET | `/closet-share/received` | 分享给我的列表 | +| GET | `/closet-share/:uuid` | 分享详情(属主 / 访客视角) | +| POST | `/closet-share/:uuid/cancel` | 取消分享 | + +**收藏 / 点赞(5)** + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/collect` | 收藏 / 取消收藏 | +| GET | `/collect` | 我的收藏列表 | +| GET | `/collect/:targetUUID` | 查询目标收藏状态与数量 | +| POST | `/like` | 点赞 / 取消点赞 | +| GET | `/like/:targetUUID` | 查询目标点赞状态与数量 | + +**通知(4)** + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/notification` | 通知列表 | +| GET | `/notification/unread` | 未读通知数 | +| GET | `/notification/:uuid` | 通知详情 | +| POST | `/notification/:uuid/read` | 标记单条已读 | + +**订单(4)** + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/order` | 创建订单(微信 JSAPI 支付) | +| POST | `/order/virtual` | 创建虚拟支付订单(小程序虚拟支付,iOS/Android 全终端) | +| GET | `/order` | 我的订单列表 | +| POST | `/order/:orderNo/cancel` | 取消订单 | +| POST | `/order/refund` | 申请退款 | + +> **注意**:当前**没有"社区广场 / 发现页"列表接口**。公开穿搭只能通过 UUID 直接访问详情(`GET /outfit/:uuid`),或出现在"我的收藏"列表中。 + +--- + +## 4. 公共数据结构 + +以下对象在多个接口中复用,后文接口章节直接引用,不再重复列出字段。 + +### 4.1 用户对象 `user_info` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| username | string | 昵称(新用户默认"微信用户") | +| avatar | string | 头像地址 | +| sex | int | 性别:`1`=男 `2`=女(`0` 或未设置时不展示) | +| birth_day | int64 | 生日(秒级时间戳);**为 0 表示未设置**,运势接口会要求先完善 | +| phone | string | 手机号 | +| is_new_user | bool | 是否本次新注册用户(用于引导页) | +| status | int | `1`=正常 `2`=临时禁用 `3`=永久禁用 `4`=已注销 | +| ban_reason | string | 封禁原因 | +| ban_start | int64 | 封禁开始时间(秒) | +| ban_expire | int64 | 封禁到期时间(秒) | +| vip_type | int | `1`=非会员 `2`=月度会员 `3`=年度会员 `4`=体验会员(新用户注册赠送) | +| vip_expire | int64 | VIP 到期时间(秒) | + +### 4.2 衣物对象 `ClothesItem` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| clothes_uuid | string | 衣物 UUID(后续查询/更新/删除均用此值) | +| category_main | string | 大类:上装 / 下装 / 连衣裙 / 外套 / 鞋 / 配饰 | +| category_sub | string | 小类:T恤 / 牛仔裤 / 运动鞋 / 发带 / 戒指 等 | +| style | string | 风格 | +| color_main | string | 主颜色 | +| color_sub | string | 副颜色 | +| thickness | string | 厚度 | +| version | string | 版型:宽松 / 修身 / 阔腿 / 紧身 / 常规合身 | +| material | string | 材质:牛仔布 / 针织 / 皮质 / 纯棉 / 羊毛 等 | +| pattern | string | 花纹 | +| sleeve_type | string | 袖型 | +| clothes_type | string | 衣长:短款 / 常规 / 中长 / 长款 | +| neckline_type | string | 领型 | +| formality | string | 正式程度 `1-5` | +| occasions | string | 适用场合 | +| visual_impact | string | 视觉冲击力 `1-5` | +| name | string | 名称 | +| remark | string | 备注 | +| processed_image | string | 抠图后的图片完整 URL(可直接展示) | + +### 4.3 穿搭对象 `OutfitItem` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| outfit_uuid | string | 穿搭 UUID | +| outfit_name | string | 穿搭名称(≤32) | +| outfit_desc | string | 穿搭描述(≤500) | +| scene | string | 适合场景(≤100) | +| source | int | 来源:`1`=用户创建 `2`=AI 推荐 | +| is_public | int | 公开:`1`=不公开 `2`=公开 | +| audit_status | int | 审核:`1`=待审核 `2`=审核通过 `3`=审核不通过 | +| items | array | 衣物列表,元素 `{"clothes_uuid": string}` | + +> ⚠️ 列表接口**不返回** `status` 字段,但数据库存在 `status`(`1`=正常 `2`=封禁 `3`=待用户保存)。AI 生成的"待保存"穿搭(`status=3`)也会出现在 `GET /outfit` 列表中,**前端需自行区分展示**(建议:AI 推荐未保存的单独归到"AI 推荐"入口,不要混入"我的搭配")。 + +### 4.4 商品对象 `GoodsItem` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| id | int64 | 商品 ID | +| goods_code | string | 商品编码(**下单时用这个**) | +| goods_name | string | 商品名称 | +| goods_type | int | `1`=会员包 `2`=上传包 `3`=AI 推荐包 `4`=衣柜分享包 `5`=搭配分享包 | +| price | int64 | 原价(**分**) | +| discount_price | int64 | 售价(**分**,实际支付金额) | +| member_days | int64 | 赠送会员天数;`>=365` 发放年度会员,否则月度会员 | +| upload_quota | int64 | 赠送上传额度(对应后端 `cos_quota`) | +| closet_share_quota | int64 | 赠送衣柜分享额度 | +| outfit_share_quota | int64 | 赠送搭配分享额度 | +| description | string | 商品描述 | + +> 商品对象**不含** AI 推荐额度展示字段,但 `goods_type=3`(AI 推荐包)会发放 AI 额度。 + +### 4.5 订单对象 `OrderItem` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| order_no | string | 订单号 | +| goods_name | string | 商品名称 | +| unit_price | int64 | 单价(**分**) | +| pay_amount | int64 | 实付金额(**分**) | +| pay_channel | int | 支付渠道 `1`=微信支付(JSAPI) `2`=虚拟支付 | +| order_status | int | `1`=待支付 `2`=已支付 `3`=已取消 `4`=已过期 `5`=已退款 | +| member_days | int64 | 订单快照:会员天数 | +| expire_time | int64 | 支付截止时间(秒),前端倒计时用(15 分钟) | +| pay_time | int64 | 支付时间(秒),未支付为 0 | +| refund_time | int64 | 退款时间(秒),未退款为 0 | +| refund_amount | int64 | 退款金额(**分**) | +| created_at | int64 | 创建时间(秒) | + +### 4.6 通知对象 `NotificationItem` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| notification_uuid | string | 通知 UUID | +| msg_type | int | `1`=点赞通知 `2`=订单通知 `3`=系统通知 | +| title | string | 标题 | +| content | string | 内容 | +| is_read | int | ⚠️ `1`=**未读** `2`=**已读**(不是布尔值,也不是 0/1) | +| created_at | int64 | 创建时间(秒) | + +### 4.7 额度流水对象 `QuotaFlowItem` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| quota_flow_uuid | string | 流水 UUID | +| quota_type | int | `1`=上传 `2`=AI `3`=社区分享 `4`=衣柜分享 | +| change_target | int | `1`=配额调整 `2`=使用 | +| change_type | int | `1`=购买获赠 `2`=活动赠送 `3`=上传消耗 `4`=删除回退 `5`=人工调整 `6`=退款回收 | +| change_num | int64 | 变更数量(**正数为增加,负数为减少**) | +| new_value | int64 | 变更后的数值 | +| reason | string | 变更原因 / 备注 | +| order_no | string | 关联订单号(购买类流水才有) | +| created_at | int64 | 创建时间(秒) | + +### 4.8 微信支付参数 `pay_params` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| timeStamp | string | ⚠️ 注意大小写(微信原生命名),直接透传给 `wx.requestPayment` | +| nonceStr | string | 随机字符串 | +| package | string | 形如 `prepay_id=wx...` | +| signType | string | 签名方式,如 `RSA` | +| paySign | string | 签名 | + +> `pay_params` 是微信原样返回的字段,**整体透传给 `wx.requestPayment` 即可,不要改名或转换大小写**。 + +--- +## 5. 接口详解 + +> 参数位置标注:`json` = JSON 请求体、`query` = URL 查询参数、`path` = 路径参数、`form` = multipart 表单。 +> 路径中的 `:xxx` 是占位参数名,调用时替换为真实值(注意 `targetUUID`、`orderNo` 的驼峰写法)。 + +--- + +### 5.1 用户模块 + +#### POST `/user/wxlogin` — 微信登录(公开) + +**业务逻辑** + +1. 前端 `wx.login()` 获取 `code`,传给后端;后端用 `code2session` 换 `openid`。 +2. **首次登录自动注册**:昵称默认"微信用户",`status=1` 正常,返回 `is_new_user=true`;同时**自动初始化 4 类免费额度**(内置默认:上传 20 / AI 推荐 3 / 搭配分享 10 / 衣柜分享 5;可由服务端配置 `UserDefaultQuota` 覆盖,允许配置为 0),并**赠送体验会员**(`vip_type=4`,内置默认 3 天;可由 `UserDefaultQuota.GiftVIPDay` 配置,配 0 表示不赠送),赠送成功后下发一条系统通知「欢迎礼包已到账」。因此新用户返回的 `vip_type` 通常为 `4`(配置为 0 时为 `1`)。 +3. 并发首次登录不会注册重复账号(依赖 `wx_open_id` 唯一索引)。 +4. 账号被禁用/注销(`status != 1`)时返回 `21001`,HTTP 仍是 200。 +5. 额度初始化失败**不影响登录**(仅后端告警)。 + +**请求**(json) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| code | string | ✅ | required | `wx.login` 获取的临时登录凭证,一次性 | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| access_token | string | 访问令牌,放入 `Authorization: Bearer` | +| refresh_token | string | 刷新令牌,用于续期 | +| expire_time | int64 | access_token 过期时间戳(秒) | +| user_info | object | [用户对象](#41-用户对象-user_info) | + +**错误码**:`10000` 参数错误 · `21002` 微信登录失败 · `21003` 微信 Code 无效 · `21001` 账号已被禁用(HTTP 200)· `29000` 用户创建失败 · `19002` 缓存错误 + +--- + +#### POST `/user/refresh-token` — 刷新 Token(公开) + +**业务逻辑** + +- ⚠️ **`refresh_token` 与 `access_token` 两个字段都必须传**,缺少 access_token 会返回 `10000` + `details:["缺少旧 access token"]`。 +- 刷新成功后,服务端**立即吊销**旧的 access_token 与 refresh_token,并返回一对全新的 token —— 前端必须**整体替换**本地存储。 +- refresh_token 旋转更新,不可重复使用同一返回值多次刷新。 + +**请求**(json) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| refresh_token | string | ✅ | required | 当前有效的 refresh_token | +| access_token | string | ✅ | 业务必填 | 当前(可能已过期)的 access_token,用于刷新后立即吊销 | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| access_token | string | 新访问令牌 | +| refresh_token | string | 新刷新令牌 | +| expire_time | int64 | 新 access_token 过期时间戳(秒) | + +**错误码**:`10000` + `缺少旧 access token` · `11001` Token 鉴权失败(无效/已被吊销)· `11002` Token 已过期 · `19002` 缓存错误 + +--- + +#### GET `/user/info` — 当前用户信息(鉴权) + +无请求参数。 + +**响应** `data` = [用户对象](#41-用户对象-user_info)。 + +> 新注册用户 `birth_day = 0`,此时调用运势接口会提示"请先完善生日信息",建议在首页做一次引导。 + +**错误码**:`11001`/`11002`/`11004` 鉴权 · `22000` 用户不存在 · `29003` 用户查询失败 + +--- + +#### PUT `/user/profile` — 更新个人资料(鉴权) + +**业务逻辑** + +- **只更新非空字段**(传什么改什么),全空则直接返回成功且不写库。 +- 昵称按字符数(汉字算 1)限制 ≤ 32,超长返回 `10000` + `details:["昵称长度不能超过 32 个字符"]`。 +- `birth_day` 不能晚于当前时间。 +- 更新后自动失效缓存,下次 `GET /user/info` 即为最新值。 + +**请求**(json,全部可选) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| username | string | 否 | `max=32` | 昵称 | +| avatar | string | 否 | `max=500` | 头像地址(建议先自行上传到 COS 再传 URL) | +| sex | int | 否 | `oneof=0 1 2` | `0`=未知(忽略) `1`=男 `2`=女 | +| birth_day | int64 | 否 | — | 生日(秒级时间戳),**决定星座与运势** | + +**响应** `data` = `{}` + +**错误码**:`10000` 参数错误(含 `昵称长度不能超过 32 个字符`、`头像地址过长`、`性别参数不合法`、`生日不能晚于当前时间`)· `29001` 用户更新失败 + +--- + +#### POST `/user/avatar/upload` — 上传头像(鉴权) + +**业务逻辑** + +- multipart 上传,字段名 `file`;仅支持图片(jpeg/png/gif/webp),复用全站图片扩展名白名单与大小限制(`MaxSizeMB`)。 +- 上传成功后写入 COS(key 规则 `{用户UUID}/{用户ID}/avatar/{随机名}.{扩展名}`),并将 COS key 存入 `users.avatar`。 +- 头像上传**不消耗** COS 上传额度。 +- 更新后自动失效缓存,`GET /user/info` 返回的头像字段为可直接访问的 URL。 + +**请求**:`multipart/form-data`,字段名 `file`。 + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| avatar | string | 头像可访问 URL | + +**错误码**:`11001`/`11002`/`11004` 鉴权 · `30000` 上传文件为空 · `30001` 不支持的文件类型 · `30002` 文件大小超过限制 · `39000` 文件保存失败 · `22000` 用户不存在 · `29001` 用户更新失败 + +--- + +### 5.2 额度模块 + +全站共 **4 类额度**:**上传额度为永久额度(不每天重置)**;**AI 推荐 / 社区分享 / 衣柜分享为每日额度(每天 0 点重置)**。免费额度由服务端配置 `UserDefaultQuota` 覆盖(下表「新用户默认」为内置兜底值,**允许配置为 0**,表示不赠送该类免费额度): + +| quota_type | 名称 | 类型 | 新用户默认 | 消耗场景 | +| --- | --- | --- | --- | --- | +| 1 | 上传额度 | 永久 | 20 | 上传一件衣物扣 1 | +| 2 | AI 推荐额度 | 每日 | 3 | 生成一次 AI 穿搭推荐扣 1 | +| 3 | 社区分享额度(搭配分享) | 每日 | 10 | 穿搭首次公开扣 1 | +| 4 | 衣柜分享额度 | 每日 | 5 | 创建一次衣柜分享扣 1 | + +> 购买赠送额度以**额度凭证**(`quota_grants`)发放,分三类来源:**会员包**(随 VIP 有效期,续期自动延长)、**加量包**(独立有效期 `goods.valid_days`,到期失效)、**广告激励**(当天 23:59:59 到期,金额由服务端 `AdReward` 配置固定,开关控制)。Cos 消费先扣「即将过期凭证」再扣免费额度;每日额度总额 = 免费配置 + 有效凭证赠送额。 + +#### GET `/quota/info` — 额度概览(鉴权) + +无请求参数。 + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| quotas | array | 额度数组,元素见下表 | + +`quotas[]`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| quota_type | int | `1`=上传 `2`=AI `3`=社区分享 `4`=衣柜分享 | +| total | int64 | 总额度(上传=永久总额度;AI/社区/衣柜=每日总额度) | +| used | int64 | 已使用(上传=永久累计已用;AI/社区/衣柜=当日已用) | +| remain | int64 | 剩余(`max(total-used, 0)`,不会为负;每日额度次日 0 点重置) | + +**错误码**:`19001` 数据库错误 · `89000` 额度更新失败 + +--- + +#### GET `/quota/flow` — 额度流水(鉴权) + +**请求**(query) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| page / page_size | int | 否 | 见[分页约定](#24-分页约定) | 分页 | +| quota_type | int | 否 | `oneof=1 2 3 4` | 额度类型筛选;不传 = 全部 | + +**响应** `data.list[]` = [额度流水对象](#47-额度流水对象-quotaflowitem) + +**错误码**:`10000` 参数错误(quota_type 非 1~4)· `19001` 数据库错误 + +--- + +#### POST `/ad-reward/grant` — 广告激励额度发放(鉴权) + +看广告后发放单次激励额度,**当天有效**(当天 23:59:59 到期),通过 `ref_id` 幂等防重复领取。 + +**业务逻辑**:广告激励由服务端 `AdReward` 配置开关控制——**未开启时返回 `80004 广告激励未开启`**;发放金额由**服务端配置固定值**(`cos`/`ai`/`outfit_share`/`closet_share` 各类型分别配置),前端**不传金额**,只传类型与广告记录 ID。 + +**请求**(JSON body) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| reward_type | string | ✅ | `oneof=cos ai outfit_share closet_share` | 激励类型,映射到额度类型 | +| ref_id | string | ✅ | — | 广告记录ID(幂等键,同 ref_id+类型 重复发放返回 `80002`) | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| ref_id | string | 广告记录ID | +| quota_type | int | `1`=上传 `2`=AI `3`=社区分享 `4`=衣柜分享 | +| amount | int64 | 发放数量(由服务端配置决定) | + +**错误码**:`80002` 广告激励已领取 · `80003` 无效的激励类型 · `80004` 广告激励未开启 · `10000` 参数错误 · `19001` 数据库错误 + +--- + +### 5.3 运势模块 + +#### GET `/horoscope/today` — 今日星座运势(鉴权) + +**业务逻辑** + +- 星座由用户 `birth_day` **自动推算**,请求不需要传星座。 +- **未设置生日** → `10000` + `details:["请先完善生日信息"]`,前端应引导跳转资料页。 +- 读取顺序:Redis 缓存(有效期至次日 0 点)→ MySQL → **都没有时异步触发生成,并当次返回 `90300`**。此时前端应提示"运势生成中,请稍后重试",3~5 秒后重试即可。 +- 十二星座运势由后端每日 0 点定时任务批量生成(Redis 分布式锁保证多实例只生成一次)。 +- 运势是 AI 推荐的输入之一,缺失不影响 AI 推荐(会降级为仅星座或空)。 + +**请求**:无参数。 + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| stat_date | int64 | 日期,格式 `YYYYMMDD`(如 `20260907`) | +| zodiac | string | 星座中文名(摩羯座 / 水瓶座 / … / 射手座) | +| overall_score | int | 综合运势 `1-5` | +| love_score | int | 爱情运势 `1-5` | +| career_score | int | 事业运势 `1-5` | +| wealth_score | int | 财运运势 `1-5` | +| daily_text | string | 今日运势文案 | +| lucky_color | string | 今日幸运色 | +| lucky_number | string | 今日幸运数字 | + +**错误码**:`10000` + `请先完善生日信息` · `90300` 星座运势不存在(生成中,请重试)· `19001` 数据库错误 + +--- + +### 5.4 衣物模块 + +#### 图片处理状态枚举 `image_process_status`(全模块通用) + +| 值 | 含义 | 前端处理 | +| --- | --- | --- | +| 1 | 抠图中 | 继续轮询 | +| 2 | 分类中 | 继续轮询 | +| 3 | 排队中 | 继续轮询 | +| 4 | **成功** | 终态:可读取识别结果、可用于 AI 推荐 | +| 5 | **失败** | 终态:查看 `fail_reason`,可重新上传 | + +--- + +#### POST `/clothes/upload` — 上传衣物图片(鉴权) + +**业务逻辑** + +1. **同步校验**:文件非空 → 扩展名白名单(默认 `jpg/jpeg/png`)→ 大小 ≤ 20MB(配置 `Upload.MaxSizeMB`)→ **magic bytes 文件头真图校验**(防伪造扩展名)。 +2. **同步扣 1 个上传额度**(`quota_type=1`),额度不足直接 `80000`,不会创建记录。 +3. 创建衣物记录,`image_process_status = 3`(排队中),**立即返回**。 +4. **后端异步流水线**(与请求无关,最多并发 10,单任务超时 3 分钟): + `3 排队中` → `1 抠图中`(BiRefNet 抠图,失败重试 3 次,间隔 1s)→ 上传 COS → `2 分类中`(Fashion-CLIP 识别 15 个属性,失败重试 3 次)→ `4 成功`(回填属性 + 抠图 URL)。 +5. **任一环节失败** → 置 `5 失败` 并写 `fail_reason`(`"图片抠图失败"` / `"图片保存失败"` / `"图片上传失败"` / `"图片分类失败"` / `"内部错误"`),**并自动回退 1 个上传额度**。 +6. **分类失败但抠图成功时**仍会保留抠好的图(`processed_image`),用户可用 `PUT /clothes/:uuid` 手动补全属性;但状态仍是 `5`。 + +> **前端必做**:拿到 `clothes_uuid` 后**轮询** `GET /clothes/:uuid/status` 直到 `4` 或 `5`。**状态 1/2/3 时不要展示"完成"**。 + +**请求**(form,`multipart/form-data`) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| file | file | ✅ | 衣物图片,**字段名固定为 `file`**,无其他表单字段 | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| clothes_uuid | string | 衣物 UUID,后续查询/更新/删除/轮询都用它 | +| image_process_status | int | 初始恒为 `3`(排队中) | + +**错误码**:`30000` 上传文件为空 · `30001` 不支持的文件类型(含 magic bytes 校验失败)· `30002` 文件大小超过限制 · `39000` 文件保存失败 · `50002` 创建衣物失败(额度已自动回退)· `80000` 额度不足 + +--- + +#### GET `/clothes` — 我的衣物列表(鉴权) + +**请求**(query) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| page / page_size | int | 否 | 分页 | +| category_main | string | 否 | 按大类筛选(上装 / 下装 / 连衣裙 / 外套 / 鞋 / 配饰) | +| category_sub | string | 否 | 按小类筛选 | + +**响应** `data`:`total` / `page` / `page_size` + `list`([衣物对象](#42-衣物对象-clothesitem) 数组) + +> 列表**包含**处理中的衣物(状态 1/2/3),其属性字段为空字符串。前端应识别并展示"处理中"占位,或按 `image_process_status` 过滤(注意:列表响应不含该字段,如需判断请结合上传后的本地状态或详情接口)。 + +**错误码**:`10000` 参数错误 · `19001` 数据库错误 + +--- + +#### GET `/clothes/:uuid` — 衣物详情(鉴权) + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| uuid | string | ✅ | 衣物 UUID | + +**响应** `data` = [衣物对象](#42-衣物对象-clothesitem) + +**错误码**:`10000` 参数错误(uuid 为空)· `50000` 衣物不存在 · `50005` 无权操作该衣物 + +--- + +#### GET `/clothes/:uuid/status` — 查询 AI 处理状态(鉴权,轮询用) + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| uuid | string | ✅ | 衣物 UUID | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| image_process_status | int | 处理状态,见 [5.4 节枚举表](#54-衣物模块) | +| fail_reason | string | 失败原因(状态为 `5` 时有效,其余为空) | + +**错误码**:`10000` 参数错误 · `50000` 衣物不存在 · `50005` 无权操作该衣物 + +--- + +#### PUT `/clothes/:uuid` — 更新衣物属性(鉴权) + +**业务逻辑** + +- 用于**修正 AI 识别结果**或用户手动补全(尤其分类失败但抠图成功时)。 +- **只更新非空字段**,全空返回成功但不写库。 +- 字段**无枚举限制**,允许自定义值(如自定义分类)。 +- ⚠️ **主颜色的请求字段是 `color`,不是 `color_main`**(响应里才是 `color_main`),这是最容易写错的地方。 +- 更新不会改变 `image_process_status`(识别失败仍是 `5`,但属性已可用)。 + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| uuid | string | ✅ | 衣物 UUID(路径参数优先,body 中的 `clothes_uuid` 会被覆盖) | + +**请求体**(json,全部可选,传什么改什么) + +| 参数 | 类型 | 约束 | 说明 | +| --- | --- | --- | --- | +| category_main | string | ≤64 | 大类 | +| category_sub | string | ≤64 | 小类 | +| style | string | ≤64 | 风格 | +| **color** | string | ≤64 | ⚠️ **主颜色(请求字段名是 `color`)** | +| color_sub | string | ≤64 | 副颜色 | +| thickness | string | ≤64 | 厚度 | +| version | string | ≤64 | 版型 | +| material | string | ≤64 | 材质 | +| pattern | string | ≤64 | 花纹 | +| sleeve_type | string | ≤64 | 袖型 | +| clothes_type | string | ≤64 | 衣长类型 | +| neckline_type | string | ≤64 | 领型 | +| formality | string | ≤16 | 正式程度 1-5 | +| occasions | string | ≤255 | 适用场合 | +| visual_impact | string | ≤16 | 视觉冲击力 1-5 | +| name | string | ≤64 | 名称 | +| remark | string | ≤500 | 备注 | + +**响应** `data` = `{}` + +**错误码**:`10000` 参数错误 · `50000` 衣物不存在 · `50004` 更新衣物失败 · `50005` 无权操作该衣物 + +--- + +#### DELETE `/clothes/:uuid` — 删除衣物(鉴权) + +**业务逻辑** + +- **软删除**,**无恢复接口**,视为不可逆操作,前端需二次确认。 +- **额度回退规则**(重要): + - 衣物状态为 `1/2/3/4`(处理中或成功)且已扣额度 → 事务内**回退 1 个上传额度**; + - 衣物状态为 `5`(处理失败)→ **不回退**(失败时已回退过,防止双退)。 +- **已被穿搭引用的衣物无法删除** → `50001` 衣服已被搭配使用,无法删除(需先从穿搭中移除)。 +- 删除后,已创建的衣柜分享里该衣物会自动过滤掉(不会崩)。 + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| uuid | string | ✅ | 衣物 UUID | + +**响应** `data` = `{}` + +**错误码**:`10000` 参数错误 · `50000` 衣物不存在 · `50001` 衣服已被搭配使用,无法删除 · `50003` 删除衣物失败 · `50005` 无权操作该衣物 + +--- +### 5.5 穿搭模块 + +**枚举速查**:`source` `1`=用户创建 `2`=AI 推荐 · `is_public` `1`=不公开 `2`=公开 · `audit_status` `1`=待审核 `2`=审核通过 `3`=审核不通过 · `status` `1`=正常 `2`=封禁 `3`=待用户保存(AI 推荐生成后未保存) + +#### POST `/outfit` — 创建穿搭(鉴权) + +**业务逻辑** + +- 1~10 件衣物,**必须全部属于当前用户**(非本人 → `50005`,不存在 → `50000`),同一穿搭内重复衣物会自动去重。 +- 创建后默认 `is_public=1` 私有、`audit_status=1` 待审核、`status=1` 正常。 +- `source` 传非 1/2 的值时服务端兜底为 `1`(用户创建)。 +- **创建不扣任何额度**,公开时才扣。 + +**请求**(json) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| outfit_name | string | ✅ | required, ≤32 | 穿搭名称 | +| outfit_desc | string | 否 | ≤500 | 穿搭描述 | +| scene | string | 否 | ≤100 | 适合场景 | +| source | int | 否 | `oneof=1 2` | `1`=用户创建 `2`=AI 推荐 | +| items | array | ✅ | `min=1, max=10` | 衣物列表,元素 `{"clothes_uuid": string}` | + +**响应** `data` = [穿搭对象](#43-穿搭对象-outfititem) + +**错误码**:`10000` 参数错误(名称超长 / items 为空或超过 10 件)· `40001` 创建穿搭失败 · `50000` 衣物不存在 · `50005` 无权操作该衣物 + +--- + +#### GET `/outfit` — 我的穿搭列表(鉴权) + +**请求**(query) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| page / page_size | int | 否 | 分页 | +| is_public | int | 否 | `1`=不公开 `2`=公开;不传 = 全部 | +| source | int | 否 | `1`=用户创建 `2`=AI 推荐;不传 = 全部 | + +**响应** `data.list[]` = [穿搭对象](#43-穿搭对象-outfititem) + +> ⚠️ 该接口**不返回 `status` 字段,也不会过滤 `status=3`(AI 待保存)的穿搭**。前端拿到列表后,建议用 `source` 区分来源;AI 推荐未保存的穿搭应引导用户去"AI 推荐"入口处理,不要混入"我的搭配"。 + +**错误码**:`10000` 参数错误 · `19001` 数据库错误 + +--- + +#### GET `/outfit/:uuid` — 穿搭详情(鉴权) + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| uuid | string | ✅ | 穿搭 UUID | + +**业务逻辑(可见性)** + +- 本人穿搭:始终可见。 +- 他人穿搭:必须同时满足 `is_public=2` + `audit_status=2` + `status=1`,否则返回 `40004`。 + +**响应** `data` = [穿搭对象](#43-穿搭对象-outfititem) + +**错误码**:`10000` 参数错误 · `40000` 穿搭不存在 · `40004` 无权操作该穿搭(他人未公开/未过审) + +--- + +#### PUT `/outfit/:uuid` — 更新穿搭(鉴权) + +**业务逻辑** + +- 只更新 `outfit_name` / `outfit_desc` / `scene` 与明细。 +- **明细采用"先删后建"**(事务内),中途失败不会留下空明细。 +- `outfit_uuid` 以路径参数为准(body 中传了也会被覆盖)。 +- 更新**不影响**公开状态与审核状态(已公开的仍是公开,不重复扣额度)。 + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| uuid | string | ✅ | 穿搭 UUID | + +**请求体**(json) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| outfit_name | string | ✅ | required, ≤32 | 穿搭名称 | +| outfit_desc | string | 否 | ≤500 | 穿搭描述 | +| scene | string | 否 | ≤100 | 适合场景 | +| items | array | ✅ | `min=1, max=10` | 衣物列表,元素 `{"clothes_uuid": string}` | + +**响应** `data` = `{}` + +**错误码**:`10000` 参数错误 · `40000` 穿搭不存在 · `40003` 更新穿搭失败 · `40004` 无权操作该穿搭 · `50000` 衣物不存在 + +--- + +#### DELETE `/outfit/:uuid` — 删除穿搭(鉴权) + +**业务逻辑** + +- 软删穿搭 + **物理删除**明细,**无恢复接口**,需二次确认。 +- **不返还任何额度**(已扣的搭配分享额度不退)。 +- 删除后,他人收藏列表与点赞记录不会自动清理。 + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| uuid | string | ✅ | 穿搭 UUID | + +**响应** `data` = `{}` + +**错误码**:`10000` 参数错误 · `40000` 穿搭不存在 · `40002` 删除穿搭失败 · `40004` 无权操作该穿搭 + +--- + +#### PUT `/outfit/:uuid/public` — 设置公开状态(鉴权) + +**业务逻辑(额度相关,重点)** + +- **公开(`is_public=2`)**:条件更新 `is_public 1→2` 且 `audit_status → 2`(**自动审核通过,无需等待人工**)→ 成功后**扣 1 个社区分享额度**(`quota_type=3`)。 + - **额度不足 → 回滚为私有**并返回 `80000`,穿搭不会公开。 + - 重复公开(已是公开态)→ **幂等成功,不重复扣额度**。 + - ⚠️ **取消公开后再次公开,会再扣 1 个额度**(因为 `is_public` 已回到 1,首次公开条件重新成立)。 +- **取消公开(`is_public=1`)**:直接置为私有,**不返还已扣额度**。 +- 公开成功后,其他用户可通过 `POST /like`、`POST /collect` 按 UUID 互动。 + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| uuid | string | ✅ | 穿搭 UUID | + +**请求体**(json) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| is_public | int | ✅ | required, `oneof=1 2` | `1`=不公开 `2`=公开 | + +**响应** `data` = `{}` + +**错误码**:`10000` 参数错误 · `40000` 穿搭不存在 · `40003` 更新穿搭失败 · `40004` 无权操作该穿搭 · `80000` 额度不足(已自动回滚为私有) + +--- + +### 5.6 AI 推荐模块 + +#### POST `/ai-recommend` — 生成 AI 穿搭推荐(鉴权) + +**业务逻辑** + +1. **并发锁**:同一用户 180 秒内只能有一个生成任务,抢锁失败返回 `10003` 请求过于频繁(**HTTP 429**),前端应提示"正在生成中,请稍候"。 +2. **生成前先扣 1 个 AI 额度**(`quota_type=2`);额度不足返回 **`90401` AI 推荐次数已达上限**(注意不是 `80000`)。 +3. 输入:当前用户**处理成功(状态 4)**的全部衣物 + 生日推算的星座 + 今日运势(星座/运势缺失会降级,不影响生成)。 +4. **衣橱中没有可用衣物** → `90400` + `details:["衣橱暂无可用衣物"]`,**额度已自动回退**。 +5. 调用大模型生成;失败或无有效单品(`details:["未生成有效搭配"]`)→ `90400`,**额度自动回退**。 +6. 成功:落库为穿搭,`source=2`、`is_public=1`、`audit_status=1`、`status=3`(**待用户保存**),并返回完整内容供预览。 +7. ⚠️ **生成成功但未调用 save,不退还 AI 额度**(额度在生成时已消耗)。 + +**请求**(json) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| scene_type | int | ✅ | required, `oneof=1 2` | `1`=每日推荐 `2`=个性化场景 | +| scene | string | 否 | — | 用户自定义场景描述(`scene_type=2` 时使用,如"参加朋友的婚礼") | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| outfit_uuid | string | 生成结果 UUID(**保存时要用**) | +| outfit_name | string | 穿搭名称 | +| outfit_desc | string | 穿搭描述 | +| scene | string | 场景 | +| source | int | `2`=AI 推荐 | +| status | int | `3`=待用户保存 | +| outfit_item | array | ⚠️ 注意字段名是 `outfit_item`(单数 item),元素 `{"clothes_uuid": string}` | + +**错误码**:`10000` 参数错误 · `10003` 请求过于频繁(429,180 秒内重复请求)· `90400` AI 推荐失败(含 `衣橱暂无可用衣物`、`未生成有效搭配`)· `90401` AI 推荐次数已达上限(AI 额度不足)· `19002` 缓存错误 · `19003` 下游服务调用失败 + +--- + +#### POST `/ai-recommend/save` — 保存 AI 推荐为我的穿搭(鉴权) + +**业务逻辑** + +- 仅 `status=3`(待用户保存)的穿搭可保存 → 置为 `status=1`。 +- 已是 `status=1` → **幂等成功**(重复保存安全)。 +- 其它状态(如已封禁)→ `40003`。 +- **保存不扣任何额度**;后续若要公开到社区,仍需调 `PUT /outfit/:uuid/public`,届时扣 1 个搭配分享额度。 + +**请求**(json) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| outfit_uuid | string | ✅ | required | 由 `/ai-recommend` 返回的 `outfit_uuid` | + +**响应** `data` = `{}` + +**错误码**:`10000` 参数错误 · `40000` 穿搭不存在 · `40003` 更新穿搭失败(状态不允许保存)· `40004` 无权操作该穿搭 + +--- + +### 5.7 衣柜分享模块 + +**状态枚举** `status`:`1`=正常 `2`=已取消 `3`=已过期 `4`=已封禁 +**分享类型** `share_type`:`1`=部分衣物分享 `2`=全量快照分享 + +#### POST `/closet-share` — 创建衣柜分享(鉴权) + +**业务逻辑** + +1. `expire_time`:`0` 表示**永不过期**;非 0 时必须晚于当前时间(否则 `10000` + `details:["过期时间必须晚于当前时间"]`)。 +2. `share_type=1`:必须传非空 `clothes_uuids`(否则 `10000` + `details:["部分分享需选择衣物"]`),且衣物必须全部属于本人。 + `share_type=2`:自动快照当前全部衣物(后端分页遍历,无需传列表)。 +3. **先扣 1 个衣柜分享额度**(`quota_type=4`),不足 → `80000`。 +4. 创建分享(`status=1` 正常)→ 写入明细;**明细写入失败会回退额度并返回 `19001`**(避免产生空分享)。 +5. 创建即自动审核通过。 + +**请求**(json) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| share_type | int | ✅ | required, `oneof=1 2` | `1`=部分衣物 `2`=全量快照 | +| clothes_uuids | array | 条件必填 | — | 衣物 UUID 数组;`share_type=1` 时必填且非空 | +| expire_time | int64 | 否 | — | 过期时间戳(秒);`0` 或省略 = 永不过期 | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| share_uuid | string | 分享 UUID,供他人访问分享详情 | + +**错误码**:`10000` 参数错误(含 `部分分享需选择衣物`、`过期时间必须晚于当前时间`)· `80000` 额度不足 · `19001` 数据库错误(额度已回退)· `50000` 衣物不存在 · `50005` 无权操作该衣物 + +--- + +#### GET `/closet-share` — 我的分享列表(鉴权) + +**请求**(query) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| page / page_size | int | 否 | 分页 | +| status | int | 否 | `1`=正常 `2`=已取消 `3`=已过期 `4`=已封禁;不传 = 全部 | + +**响应** `data.list[]` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| share_uuid | string | 分享 UUID | +| share_type | int | `1`=部分衣物 `2`=全量快照 | +| clothes_count | int | 衣物数量 | +| expire_time | int64 | 过期时间戳(秒),`0`=永不过期 | +| status | int | `1`=正常 `2`=已取消 `3`=已过期 `4`=已封禁 | +| owner_name | string | 分享者昵称(本接口暂不返回,恒为空) | +| owner_avatar | string | 分享者头像(本接口暂不返回,恒为空) | + +> ⚠️ **过期是"惰性"的**:`expire_time` 到了但没人访问时,列表里 `status` 可能仍是 `1`,直到有人访问详情才变成 `3`。**前端应同时判断 `status` 与 `expire_time`** 来展示"已过期"。 + +**错误码**:`10000` 参数错误 · `19001` 数据库错误 + +--- + +#### GET `/closet-share/received` — 分享给我的列表(鉴权) + +**业务逻辑** + +- "访问即视为收到":登录用户(非分享属主)成功访问他人分享详情时,自动记录接收关系;重复访问不重复记录,属主访问不记录。 +- 列表按接收记录倒序分页返回,支持按 `status` 筛选。 + +**请求**(query) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| page / page_size | int | 否 | 分页 | +| status | int | 否 | `1`=正常 `2`=已取消 `3`=已过期 `4`=已封禁;不传 = 全部 | + +**响应** `data.list[]` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| share_uuid | string | 分享 UUID | +| share_type | int | `1`=部分衣物 `2`=全量快照 | +| clothes_count | int | 衣物数量 | +| expire_time | int64 | 过期时间戳(秒),`0`=永不过期 | +| status | int | `1`=正常 `2`=已取消 `3`=已过期 `4`=已封禁 | +| owner_name | string | 分享者昵称 | +| owner_avatar | string | 分享者头像 | + +> ⚠️ 过期与状态判定同"我的分享列表",前端应同时判断 `status` 与 `expire_time`。 + +**错误码**:`10000` 参数错误 · `19001` 数据库错误 + +--- + +#### GET `/closet-share/:uuid` — 分享详情(鉴权,属主 / 访客视角) + +**业务逻辑** + +- 属主与访客都可访问(需已登录,`is_owner` 区分视角)。 +- **访客访问会 +1 访问计数**,属主自己访问不计数。 +- 状态处理:`2` 已取消 → `90102`;`4` 已封禁 → `90100`(**故意伪装成"不存在"**);`3` 已过期 → `90101`;`expire_time` 已过 → 惰性置为 `3` 并返回 `90101`。 +- 已被软删除的衣物会自动从分享中过滤。 + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| uuid | string | ✅ | 分享 UUID | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| share_uuid | string | 分享 UUID | +| owner_name | string | 分享者昵称 | +| owner_avatar | string | 分享者头像 | +| clothes | array | [衣物对象](#42-衣物对象-clothesitem) 数组 | +| expire_time | int64 | 过期时间戳(秒) | +| status | int | `1`=正常 `2`=已取消 `3`=已过期 `4`=已封禁 | +| is_owner | bool | 是否本人分享(`true` 时隐藏"收藏/点赞"入口) | + +**错误码**:`10000` 参数错误 · `90100` 分享不存在(含已封禁)· `90101` 分享已过期 · `90102` 分享已取消 + +--- + +#### POST `/closet-share/:uuid/cancel` — 取消分享(鉴权) + +**业务逻辑** + +- **仅属主可操作**,他人 → `12000`(HTTP 403)。 +- 置 `status=2`,**不可逆,且不返还衣柜分享额度**,前端需二次确认。 + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| uuid | string | ✅ | 分享 UUID | + +**响应** `data` = `{}` + +**错误码**:`10000` 参数错误 · `12000` 无权限访问(403)· `90100` 分享不存在 · `19001` 数据库错误 + +--- + +### 5.8 收藏与点赞模块 + +**动作枚举** `action`:`1`=执行(点赞/收藏)`2`=取消。两个接口都**幂等**,重复提交无副作用。 + +#### POST `/collect` — 收藏 / 取消收藏(鉴权) + +**业务逻辑** + +- 目标为社区穿搭 UUID。目标不存在 → `90200`;**非本人且未公开可见** → `40004`。 +- 取消时仅当当前为"已收藏"才更新为已取消;**记录不会物理删除**,可再次收藏。 +- **收藏不触发任何通知**。 + +**请求**(json) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| target_uuid | string | ✅ | required | 目标穿搭 UUID | +| action | int | ✅ | required, `oneof=1 2` | `1`=收藏 `2`=取消收藏 | + +**响应** `data` = `{}` + +**错误码**:`10000` 参数错误 · `90200` 目标内容不存在 · `90202` 收藏操作失败 · `40004` 无权操作该穿搭 · `19001` 数据库错误 + +--- + +#### GET `/collect` — 我的收藏列表(鉴权) + +**请求**(query):仅 `page` / `page_size`。 + +**响应** `data.list[]` = [穿搭对象](#43-穿搭对象-outfititem) + +**错误码**:`10000` 参数错误 · `19001` 数据库错误 + +--- + +#### GET `/collect/:targetUUID` — 查询收藏状态与数量(鉴权) + +进入穿搭详情时用于初始化收藏按钮状态。 + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| **targetUUID** | string | ✅ | ⚠️ 路径参数名是 `targetUUID`(驼峰),不是 `uuid` | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| is_collected | bool | 当前用户是否已收藏 | +| collect_count | int64 | 该穿搭收藏总数 | + +**错误码**:`10000` 参数错误 · `90200` 目标内容不存在 · `40004` 无权操作该穿搭 · `19001` 数据库错误 + +--- + +#### POST `/like` — 点赞 / 取消点赞(鉴权) + +**业务逻辑** + +- 目标为社区穿搭 UUID。目标不存在 → `90200`;**非本人且未公开可见** → `40004`。 +- **通知触发条件**:仅当状态从"无记录或已取消"变为"已点赞"、且**不是给自己点赞**时,才向穿搭作者发一条 `msg_type=1` 通知(标题"收到新的点赞",内容"你的穿搭「{名称}」收到了一个赞")。**重复点赞不会重复通知**。 + +**请求**(json) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| target_uuid | string | ✅ | required | 目标穿搭 UUID | +| action | int | ✅ | required, `oneof=1 2` | `1`=点赞 `2`=取消点赞 | + +**响应** `data` = `{}` + +**错误码**:`10000` 参数错误 · `90200` 目标内容不存在 · `90201` 点赞操作失败 · `40004` 无权操作该穿搭 · `19001` 数据库错误 + +--- + +#### GET `/like/:targetUUID` — 查询点赞状态与数量(鉴权) + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| **targetUUID** | string | ✅ | ⚠️ 路径参数名是 `targetUUID`(驼峰),不是 `uuid` | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| is_liked | bool | 当前用户是否已点赞 | +| like_count | int64 | 该穿搭点赞总数 | + +**错误码**:`10000` 参数错误 · `19001` 数据库错误 + +--- + +### 5.9 通知模块 + +**`is_read` 取值**:⚠️ `1`=**未读** `2`=**已读**(非常规的 0/1,也不是布尔值) +**`msg_type` 取值**:`1`=点赞通知 `2`=订单通知 `3`=系统通知(当前代码无发送点,预留) + +**通知触发点(仅 3 处)** + +| 触发动作 | msg_type | 标题 | 内容 | 接收者 | +| --- | --- | --- | --- | --- | +| 他人点赞我的公开穿搭 | `1` | 收到新的点赞 | 你的穿搭「{OutfitName}」收到了一个赞 | 穿搭作者(非本人点赞才触发) | +| 支付成功 | `2` | 订单支付成功 | 你购买的「{GoodsName}」已到账 | 买家(sender=0 系统) | +| 退款成功 | `2` | 退款成功 | 订单「{GoodsName}」已退款 | 买家(sender=0 系统) | + +> 通知**无推送能力**(无 WebSocket/订阅消息),前端需**轮询** `GET /notification/unread` 刷新红点。 + +#### GET `/notification` — 通知列表(鉴权) + +**请求**(query) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| page / page_size | int | 否 | — | 分页 | +| is_read | int | 否 | `oneof=1 2` | ⚠️ `1`=未读 `2`=已读;不传 = 全部(**不是布尔值**) | +| msg_type | int | 否 | `oneof=1 2 3` | `1`=点赞 `2`=订单 `3`=系统;不传 = 全部(可与 `is_read` 组合筛选) | + +**响应** `data.list[]` = [通知对象](#46-通知对象-notificationitem) + +**错误码**:`10000` 参数错误 · `19001` 数据库错误 + +--- + +#### GET `/notification/unread` — 未读通知数(鉴权) + +**业务逻辑**:优先读 Redis 计数;Redis 为 0 或异常时回落 MySQL 统计并回填缓存。计数为 best-effort,可能短暂偏小,**展示红点时以列表接口为准更稳妥**。 + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| unread_count | int64 | 未读通知数 | + +**错误码**:`19001` 数据库错误 · `19002` 缓存错误 + +--- + +#### GET `/notification/:uuid` — 通知详情(鉴权) + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| uuid | string | ✅ | 通知 UUID | + +**响应** `data` = [通知对象](#46-通知对象-notificationitem) + +**错误码**:`10000` 参数错误 · `90000` 通知不存在 · `12000` 无权限访问 + +--- + +#### POST `/notification/:uuid/read` — 标记已读(鉴权) + +**业务逻辑** + +- **条件更新**(仅"未读→已读"),防并发重复扣减未读数;已读时直接返回当前未读数,幂等。 +- ⚠️ **没有"全部已读"接口**,需要全标已读时前端需逐条调用。 + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| uuid | string | ✅ | 通知 UUID | + +**响应** `data` = `{}` + +**错误码**:`10000` 参数错误 · `90000` 通知不存在 · `19001` 数据库错误 + +--- + +### 5.10 商品模块(公开,无需登录) + +**`goods_type` 枚举**:`1`=会员包 `2`=上传包 `3`=AI 推荐包 `4`=衣柜分享包 `5`=搭配分享包 +**`status`**:`1`=上架 `2`=下架(列表只返回上架商品) + +#### GET `/goods` — 商品列表(公开) + +**请求**(query) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| page / page_size | int | 否 | 分页 | +| goods_type | int | 否 | 商品类型筛选;不传或 `0` = 全部 | + +**响应** `data.list[]` = [商品对象](#44-商品对象-goodsitem)(**仅上架商品**) + +**错误码**:`10000` 参数错误 · `19001` 数据库错误 + +--- + +#### GET `/goods/:code` — 按编码查询商品(公开) + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| code | string | ✅ | 商品编码 `goods_code`(**不是数字 ID**) | + +**响应** `data` = [商品对象](#44-商品对象-goodsitem) + +**错误码**:`10000` 参数错误 · `60000` 商品不存在 · `60001` 商品已下架 + +--- + +### 5.11 订单与退款模块 + +**`order_status` 枚举**:`1`=待支付 `2`=已支付 `3`=已取消 `4`=已过期 `5`=已退款 +**退款 `status` 枚举**:`0`=待处理 `1`=退款中 `2`=退款成功 `3`=退款失败 + +#### POST `/order` — 创建订单并发起微信支付(鉴权) + +**业务逻辑** + +1. 只用 `goods_code` 下单,**金额由服务端按商品售价 `discount_price` 计算**,前端**不要传金额**。 +2. 商品必须存在且上架(否则 `60000` / `60001`)。 +3. 用户必须有 `wx_open_id`(即必须通过微信登录),否则 `79000` + `details:["缺少微信 openid"]`。 +4. **支付有效期 15 分钟**,`expire_time` 用于倒计时。 +5. 下单时把商品权益(会员天数 / 各类额度)**快照进订单**,后续商品改价不影响已下单权益。 +6. **先落本地待支付订单,再调微信 JSAPI**。微信下单失败返回 `79000`,但**本地订单保留为待支付**,15 分钟后自动过期。 +7. 同一用户可并存多个待支付订单(**无防重限制**),前端应避免重复点击。 + +**请求**(json) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| goods_code | string | ✅ | required | 商品编码(**唯一请求字段**) | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| order_no | string | 订单号(后续取消/退款/查询用) | +| pay_amount | int64 | 应付金额(**分**) | +| expire_time | int64 | 支付截止时间戳(秒),倒计时用(15 分钟) | +| pay_params | object | 微信支付参数,[见此](#48-微信支付参数-pay_params),**整体透传给 `wx.requestPayment`** | + +**错误码**:`10000` 参数错误 · `60000` 商品不存在 · `60001` 商品已下架 · `79000` 创建订单失败(含 `缺少微信 openid`)· `19001` 数据库错误 + +--- + +#### POST `/order/virtual` — 创建虚拟支付订单(鉴权) + +小程序虚拟商品(会员 / 额度包)**必须走虚拟支付**(微信 2026-04-01 起全终端强制)。本接口本地落单(`pay_channel=2`)后返回前端调起 `wx.requestVirtualPayment` 所需的签名参数,**不调用微信 JSAPI 下单**。 + +**业务逻辑** + +1. 服务端校验虚拟支付已开通、商品上架且已发布为虚拟支付道具(`product_id` 非空、`product_status=2`)。 +2. 下单时把商品权益快照进订单;`outTradeNo` 复用本地订单号。 +3. 服务端用 `offerId` + 现网 `appKey` 生成 `signData` / `paySig`,用登录缓存的 `session_key` 生成 `signature`,原样返回前端。 +4. 支付结果**不由前端回调决定**:前端 `success` 可能丢失,最终以服务端收到的「道具发货推送」为准。 + +**请求**(json) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| goods_code | string | ✅ | required | 商品编码 | +| platform | string | ✅ | `oneof=android ios harmony windows` | 支付平台,前端按 `wx.getSystemInfoSync().platform` 上报 | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| order_no | string | 订单号(即 `outTradeNo`) | +| pay_amount | int64 | 实付金额(**分**) | +| expire_time | int64 | 支付截止时间戳(秒),15 分钟 | +| sign_data | string | 参与签名的 JSON 字符串,**原样传给 `wx.requestVirtualPayment`,勿 JSON.parse/stringify** | +| pay_sig | string | 支付签名(服务端生成) | +| signature | string | 用户态签名(服务端生成) | + +**前端调用契约**(示例) + +```js +const res = await post('/order/virtual', { goods_code, platform: wx.getSystemInfoSync().platform }) +wx.requestVirtualPayment({ + signData: res.data.sign_data, // 原样透传,勿重新序列化 + paySig: res.data.pay_sig, + signature: res.data.signature, + success: () => { /* 仅表示拉起成功,仍需轮询 GET /order 确认 order_status=2 */ }, + fail: (err) => { /* requestVirtualPayment:fail cancel = 用户取消 */ }, +}) +``` + +**错误码**:`10000` 参数错误 · `60000` 商品不存在 · `60001` 商品已下架 · `70005` 虚拟支付未开通 · `70006` 商品尚未发布为虚拟支付道具 · `70007` 虚拟支付订单不支持取消 · `70008` iOS 虚拟支付最低 1 元 · `79003` 创建虚拟支付订单失败 · `19001` 数据库错误 + +> iOS 虚拟支付限制:最低 1 元(低于 100 分返回 `70008`)、iOS 15+、微信 8.0.68+、不支持沙箱;结算由 Apple(45~60 天)。iOS 订单退款用户走 App Store 申请,服务端会收到退款问询推送并应答。 +> +> **运营约定**:虚拟支付道具的发布与改价由**管理后台**负责(服务端**不会自动同步**道具)。商品发布为道具后回填 `product_id` / `product_status=2` 才可下单;**运营改价后必须通过管理后台同步微信侧道具价格**,否则微信侧道具价格与本地不一致:微信扣款多于订单金额时发货金额校验会拒绝(用户付款拿不到权益),少于订单金额时产生差价资损。 + +--- + +#### GET `/order` — 我的订单列表(鉴权) + +**请求**(query) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| page / page_size | int | 否 | — | 分页 | +| status | int | 否 | `oneof=1 2 3 4 5` | 订单状态筛选;不传 = 全部 | + +**响应** `data.list[]` = [订单对象](#45-订单对象-orderitem) + +**错误码**:`10000` 参数错误 · `19001` 数据库错误 + +--- + +#### POST `/order/:orderNo/cancel` — 取消订单(鉴权) + +**业务逻辑** + +- **虚拟支付订单(`pay_channel=2`)不可取消** → `70007`(虚拟支付无服务端关单 API,取消后微信侧单仍可支付,会造成付款不到账)。 +- **仅 `1` 待支付**订单可取消;已支付 → `70002`;其它状态 → `70001`。 +- 条件更新 `1→3`,失败 → `70001`。 +- 本地取消后后端尽力调用微信关单(失败仅告警,不影响结果)。 +- **不可撤销**,但可重新下单。 + +**请求**(path) + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| **orderNo** | string | ✅ | ⚠️ 路径参数名是 `orderNo`(驼峰),不是 `uuid` | + +**响应** `data` = `{}` + +**错误码**:`10000` 参数错误 · `70000` 订单不存在 · `70001` 订单状态不正确 · `70002` 订单已支付 · `79001` 取消订单失败 · `12000` 无权限访问 + +--- + +#### POST `/order/refund` — 申请退款(鉴权) + +**业务逻辑(重要:人工审核模式)** + +- **iOS 虚拟支付订单不可主动退款** → `71004`,请引导用户走 App Store 申请退款(Apple 通过退款问询推送回收权益)。 +- **仅 `2` 已支付**订单可申请,其它状态 → `71001`。 +- ⚠️ **系统不会自动调微信退款**:接口只是登记退款申请(`status=0` 待处理),**需运营人工审核并手动发起退款**——普通支付订单在微信支付商户后台退款,**虚拟支付订单(`pay_channel=2`)请在虚拟支付后台(Midas)发起退款**,退款结果由推送回调同步。 +- 前端应提示"**退款审核中**",并允许轮询订单状态直到 `5` 已退款;**不要承诺实时到账**。 +- 同一订单已有退款记录且状态不是 `3`(失败)→ `71001`;上次失败可重新申请(复用记录、换新退款单号)。 +- 退款成功后,订单置 `5`,**会员天数与已发放额度会被回收**(已消耗部分不回收),并发退款通知。 + +**请求**(json) + +| 参数 | 类型 | 必填 | 约束 | 说明 | +| --- | --- | --- | --- | --- | +| order_no | string | ✅ | required | 订单号 | +| reason | string | ✅ | required, ≤200 | 退款原因 | + +**响应** `data` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| refund_no | string | 退款单号 | +| refund_amount | int64 | 退款金额(**分**) | +| status | int | `0`=待处理 `1`=退款中 `2`=退款成功 `3`=退款失败 | +| apply_time | int64 | 申请时间(秒) | + +**错误码**:`10000` 参数错误 · `70000` 订单不存在 · `71000` 退款记录不存在 · `71001` 当前状态不允许退款 · `71002` 退款金额不合法 · `71003` 退款申请失败 · `71004` iOS 订单请通过 App Store 申请退款 + +--- + +#### POST `/order/pay/notify` · POST `/order/refund/notify` — 微信回调(公开) + +🚫 **这是微信服务端回调地址,前端绝对不要调用。** + +- 不走统一 `{code,msg,data}` 响应包装,按微信 V3 规范返回:成功 HTTP 200 `{"code":"SUCCESS","message":"成功"}`;失败 HTTP 500 `{"code":"FAIL","message":"<原因>"}`。 +- 服务端处理:验签 + 解密 → 幂等锁(30 秒)→ **金额校验**(与订单金额不一致则拒绝流转、人工介入)→ 流转订单状态 → 发放/回收权益 → 发站内通知。 +- 竞态兜底:已过期但已付款 → 补发货;已取消但已付款 → **自动发起微信退款**。 + +--- +## 6. 业务逻辑与状态机 + +### 6.1 七条业务主线(总览) + +```mermaid +flowchart TD + A["微信登录
新用户自动注册"] --> B["初始化免费额度
默认 上传 20 / AI 3 / 搭配分享 10 / 衣柜分享 5
(可配置,允许为 0)"] + B --> C["上传衣物
校验 → 扣上传额度 → 异步 AI 抠图 + 分类"] + C --> D["衣橱管理
查看 / 修正属性 / 删除回退额度"] + D --> E["创建穿搭"] + D --> F["AI 穿搭推荐
扣 AI 额度 → 大模型生成 → 待用户保存"] + D --> G["衣柜分享
部分衣物 / 全量快照"] + E --> H["公开到社区
自动审核通过 → 扣搭配分享额度"] + H --> I["社区互动
点赞 → 触发站内通知"] + F --> E + B --> J{"额度不足?"} + J -- "是" --> K["选购商品 → 微信支付 → 权益到账"] + K --> B +``` + +1. **注册与额度**:微信登录首次自动注册,同步初始化 4 类免费额度(内置默认 上传 20 / AI 3 / 搭配分享 10 / 衣柜分享 5,可由 `UserDefaultQuota` 配置,允许为 0)。 +2. **衣物生命周期**:上传(校验 + 扣额)→ 异步 AI 处理(抠图 → 分类)→ 成功入库可被选用 → 删除软删并回退额度。 +3. **穿搭与社区**:创建穿搭 → 公开(自动审核通过 + 扣额度)→ 他人点赞触发通知。 +4. **衣柜分享**:部分衣物或全量快照 → 扣额度 → 属主/访客查看 → 取消或惰性过期。 +5. **交易与权益**:选购商品 → 下单(15 分钟有效)→ 微信支付回调发放权益 → 超时过期 / 退款回收。 +6. **AI 推荐**:扣 AI 额度 → 结合星座运势与衣橱生成 → 落库"待用户保存" → 保存转正式穿搭。 +7. **通知**:点赞、支付成功、退款成功触发站内通知,未读计数实时维护(需前端轮询)。 + +--- + +### 6.2 衣物 AI 处理状态机 + +```mermaid +stateDiagram-v2 + [*] --> queued : 上传成功(同步扣上传额度 1 点) + queued --> matting : 异步任务开始 + matting --> classifying : 抠图完成(上传 COS) + classifying --> success : 分类完成,回填 15 维属性 + classifying --> failed : 分类失败(保留抠图结果) + matting --> failed : 抠图失败 + queued --> failed : 保存/上传 COS 异常 + success --> [*] : 可被穿搭/AI 推荐选用 + failed --> [*] : 用户可修正属性或删除(额度已自动回退) + + state "1 抠图中" as matting + state "2 分类中" as classifying + state "3 排队中" as queued + state "4 成功" as success + state "5 失败(写 fail_reason)" as failed +``` + +**关键规则** + +- 上传接口**同步返回**(不等待 AI),返回即 `3 排队中`。 +- 异步流水线并发上限 **10**,单任务超时 **3 分钟**;抠图/分类各带重试(默认 3 次,间隔 1 秒)。 +- 失败时 `fail_reason` 取值:`图片抠图失败` / `图片保存失败` / `图片上传失败` / `图片分类失败` / `内部错误`。 +- **失败自动回退上传额度**,三重防重防护:状态已是 5 不再退、衣物已删除不再退、删除接口用行锁 + 状态判断。 +- 只有 `4 成功` 的衣物才会被 AI 推荐采用。 + +--- + +### 6.3 穿搭状态机(公开 / 审核 / 保存) + +```mermaid +stateDiagram-v2 + [*] --> created : 创建穿搭(1~10 件本人衣物) + created --> private : 默认 is_public=1, audit_status=1, status=1 + private --> public : 公开(审核自动通过 + 扣搭配分享额度 1 点) + public --> private : 取消公开(不返还额度,再次公开会再扣) + public --> interacted : 他人点赞(触发通知)/ 收藏(不通知) + private --> [*] : 修改(明细先删后建)/ 删除(软删 + 明细物理删) + public --> [*] : 修改 / 删除(均不返还额度) + + state "1 私有(待审核)" as private + state "2 公开(审核通过)" as public + state "社区互动" as interacted +``` + +**AI 推荐穿搭的额外状态**:`status=3` 待用户保存 → 调 `/ai-recommend/save` → `status=1` 正常。 + +**可见性判定**:他人可见需同时满足 `is_public=2` 且 `audit_status=2` 且 `status=1`,否则 `40004`。 + +--- + +### 6.4 衣柜分享状态机 + +```mermaid +stateDiagram-v2 + [*] --> normal : 创建分享(扣衣柜分享额度 1 点 + 自动审核通过) + normal --> cancelled : 用户取消(仅属主,不返还额度,不可逆) + normal --> expired : 惰性过期(访问详情时判定 now > ExpireTime) + normal --> banned : 封禁(预留状态,对访问者隐藏为"不存在") + + state "1 正常" as normal + state "2 已取消" as cancelled + state "3 已过期" as expired + state "4 已封禁" as banned +``` + +**关键规则** + +- `expire_time = 0` 表示永不过期。 +- **惰性过期**:到时间但无人访问时列表 `status` 仍为 `1`,前端必须同时判断 `status` 与 `expire_time`。 +- 访客访问 +1 访问计数,属主访问不计数。 +- 删除衣物**不会**清理分享明细,被软删的衣物在分享详情中自动过滤。 + +--- + +### 6.5 订单状态机 + +```mermaid +stateDiagram-v2 + [*] --> pending : 创建订单(快照权益 + 微信 JSAPI 下单,15 分钟有效) + pending --> paid : 支付回调 SUCCESS(验签 + 金额校验 → 发放权益 → 通知) + pending --> expired : 超时 15 分钟(后台每分钟扫描) + pending --> cancelled : 用户主动取消(同步关闭微信订单) + expired --> paid : 过期后付款到账(补发货) + cancelled --> refunded : 已取消但已付款 → 生成待审核退款单,管理员手动退款 + paid --> refunded : 退款成功回调(回收权益 → 通知) + + state "1 待支付" as pending + state "2 已支付" as paid + state "3 已取消" as cancelled + state "4 已过期" as expired + state "5 已退款" as refunded +``` + +**关键规则** + +- **金额校验**:回调金额与订单 `pay_amount` 不一致 → 拒绝流转、不发货、转人工。 +- **权益发放**:会员天数按快照实际发放,会员类型按**续费后最终到期时长**推导(剩余 ≥365 天为年度,否则月度,避免年度会员续月包被降级);上传 / AI / 衣柜分享 / 搭配分享额度改为写入**额度凭证** `quota_grants`(会员包 `expire_time=0` 跟随 VIP、加量包按 `goods.valid_days` 独立到期)。 +- **幂等 + 补偿**:`benefit_grant_tasks` 唯一索引 + "发放中"状态防并发;失败自动落任务,**每分钟重试**,重试 3 次仍失败则标记失败并告警人工介入。 +- **退款回收**:会员天数按快照回退(不回退到过去);赠送额度通过**失效凭证**回收未用部分,**已消耗部分不追回**。 + +--- + +### 6.6 额度规则全景 + +**扣减 / 回退 / 发放 / 回收全部走事务 + 幂等流水(唯一索引 `(order_no, quota_type, change_type)` 兜底),前端无需做补偿。** + +| 场景 | 额度处理 | 前端提示 | +| --- | --- | --- | +| 上传衣物(建记录成功) | **扣 1 个上传额度** | — | +| 上传衣物但建记录失败 | **自动回退** | 重试即可,不会丢额度 | +| 衣物抠图/分类/保存失败 | **自动回退**(状态非 5 且未删除) | 提示识别失败,可重传 | +| 删除"处理中/成功"的衣物 | **回退 1 个上传额度** | 可提示"已返还 1 次上传额度" | +| 删除"处理失败"的衣物 | **不回退**(失败时已退过) | 不要提示返还 | +| 穿搭**首次**公开 | **扣 1 个搭配分享额度** | 公开前展示额度消耗确认 | +| 穿搭重复公开 | **不重复扣**(幂等) | — | +| **取消公开后再次公开** | **再扣 1 个额度** | ⚠️ 重点提示用户 | +| 穿搭取消公开 | **不返还** | — | +| 穿搭删除 | **不返还** | — | +| AI 推荐生成(成功) | **扣 1 个 AI 额度** | 生成前展示剩余次数 | +| AI 推荐失败(衣橱为空/模型失败/无有效单品/落库失败) | **自动回退** | 可安全重试 | +| **AI 推荐成功但未保存** | **不返还** | ⚠️ 重点提示用户及时保存 | +| 创建衣柜分享 | **扣 1 个衣柜分享额度** | — | +| 创建衣柜分享但明细写入失败 | **自动回退** | 重试即可 | +| 取消衣柜分享 | **不返还** | 二次确认 | +| 购买商品支付成功 | **发放**(按订单快照) | 展示"已到账" | +| 退款成功 | **回收**(`total >= used`,已用部分不退) | 提示额度已回收 | + +**额度不足时**:前端跳商品列表引导购买。注意 AI 额度不足返回的是 `90401`(不是 `80000`)。 + +--- + +### 6.7 通知触发规则 + +| 触发动作 | msg_type | 发送者 | 接收者 | 触发条件与防重 | +| --- | --- | --- | --- | --- | +| 点赞 | `1` | 点赞者 | 穿搭作者 | 仅"从取消/无记录 变为 已点赞"且**非本人**时触发;重复点赞不重复通知 | +| 支付成功 | `2` | 系统(sender=0) | 买家 | 权益发放成功后触发;取消/过期不发 | +| 退款成功 | `2` | 系统(sender=0) | 买家 | 权益回收后触发 | +| 系统通知 | `3` | 预留 | — | 常量已定义,当前代码无发送点 | + +- 通知写入 MySQL + Redis 未读计数自增,best-effort(失败仅告警,不影响主业务)。 +- **无推送能力,前端需轮询** `GET /notification/unread`。 + +--- + +## 7. 核心调用时序图 + +### 7.1 登录与 Token 刷新 + +```mermaid +sequenceDiagram + participant F as 小程序前端 + participant B as 后端服务 + participant W as 微信服务端 + + F->>B: POST /user/wxlogin {code} + B->>W: code2session(code) + W-->>B: openid / session_key + B->>B: 首次登录:注册用户 + 初始化 4 类免费额度 + B-->>F: {access_token, refresh_token, expire_time, user_info} + + Note over F,B: 后续请求携带 Authorization: Bearer + + F->>B: 任意鉴权接口 + B-->>F: code 11002 / HTTP 401(Token 已过期) + + F->>B: POST /user/refresh-token {refresh_token, access_token} + B-->>F: 全新 {access_token, refresh_token, expire_time} + Note over F: 必须整体替换本地 token(旧的已立即失效) + F->>B: 携带新 token 重试原请求 +``` + +### 7.2 衣物上传与 AI 处理轮询 + +```mermaid +sequenceDiagram + participant F as 小程序前端 + participant B as 后端服务 + participant M as 抠图服务(8001) + participant C as 分类服务(8000) + + F->>B: POST /clothes/upload (multipart, 字段名 file) + B->>B: 校验扩展名/大小/magic bytes + 扣 1 个上传额度 + B-->>F: {clothes_uuid, image_process_status: 3} + + Note over B,C: 异步:3 排队 → 1 抠图 → 2 分类 → 4 成功 / 5 失败 + B->>M: 抠图请求(失败重试 ≤3 次,间隔 1s) + B->>C: 15 维分类请求(失败重试 ≤3 次) + + loop 前端轮询(建议 1~2 秒) + F->>B: GET /clothes/:uuid/status + B-->>F: {image_process_status, fail_reason} + end + + alt 状态 = 4 成功 + F->>B: GET /clothes/:uuid 读取识别结果 + opt 识别不准 + F->>B: PUT /clothes/:uuid {color: "...", ...} + end + else 状态 = 5 失败 + Note over F: 展示 fail_reason,可重新上传(额度已自动回退) + end +``` + +### 7.3 AI 穿搭推荐与保存 + +```mermaid +sequenceDiagram + participant F as 小程序前端 + participant B as 后端服务 + participant L as 大模型网关(Tokenhub) + + F->>B: POST /ai-recommend {scene_type, scene?} + B->>B: Redis 锁 180s(防并发,失败返回 10003) + B->>B: 扣 1 个 AI 额度(不足 → 90401) + B->>L: 结合星座运势 + 衣橱(状态 4)衣物生成 + L-->>B: 推荐结果 + B->>B: 校验结果(过滤模型幻觉衣物 ID) + B->>B: 落库 source=2, status=3 待用户保存 + B-->>F: {outfit_uuid, outfit_name, outfit_item, ...} + + alt 生成失败 + B->>B: 回退 AI 额度 + 写失败流水 + B-->>F: code 90400 + end + + F->>F: 用户预览、确认 + F->>B: POST /ai-recommend/save {outfit_uuid} + B-->>F: 保存成功(status 3 → 1,不扣额度) + + opt 公开到社区 + F->>B: PUT /outfit/:uuid/public {is_public: 2} + B->>B: 自动审核通过 + 扣 1 个搭配分享额度 + end +``` + +### 7.4 下单到支付完成 + +```mermaid +sequenceDiagram + participant F as 小程序前端 + participant B as 后端服务 + participant W as 微信支付 + + F->>B: GET /goods 选品 + F->>B: POST /order {goods_code} + B->>B: 按售价计算金额 + 快照权益,创建订单(15 分钟有效) + B->>W: 微信 JSAPI 下单 + B-->>F: {order_no, pay_amount, expire_time, pay_params} + + F->>W: wx.requestPayment(pay_params) + W-->>B: POST /order/pay/notify(验签 + 解密) + B->>B: 金额校验 → 订单 1→2 → 发放权益 → 发站内通知 + + loop 前端轮询(1 秒起) + F->>B: GET /order + B-->>F: order_status + end + Note over F: 以服务端 order_status=2 为准,
不要只信支付组件回调 + + opt 超时未支付 + B->>B: 后台任务每分钟扫描,超 15 分钟置为 4 已过期 + end +``` + +### 7.5 点赞 / 收藏切换 + +```mermaid +sequenceDiagram + participant F as 小程序前端 + participant B as 后端服务 + + F->>B: GET /like/:targetUUID 获取初始状态 + B-->>F: {is_liked, like_count} + + F->>B: POST /like {target_uuid, action: 1} + B->>B: 状态 无/已取消 → 已点赞,且非本人 → 向作者发通知 + B-->>F: 成功(幂等) + + F->>B: POST /like {target_uuid, action: 2} + B-->>F: 成功(幂等,记录保留可再次点赞) + + Note over F,B: 收藏同理:POST /collect {target_uuid, action},但收藏不触发通知 +``` + +--- + +## 8. 错误码全表 + +> **前端统一以响应体 `code` 为准**(HTTP 状态码仅供参考)。全量 83 个错误码如下。 + +### 8.1 通用 / 鉴权 / 权限 / 系统(16) + +| code | 含义 | HTTP | 前端建议 | +| --- | --- | --- | --- | +| 0 | 成功 | 200 | — | +| 10000 | 请求参数错误 | 200 | 展示 `details[0]` | +| 10001 | 资源不存在 | 200 | 通用兜底 | +| 10002 | 请求方法不允许 | 200 | 检查 Method | +| 10003 | 请求过于频繁 | **429** | 稍后重试(AI 推荐 180s 并发锁等) | +| 11000 | 未授权 | **401** | 跳登录 | +| 11001 | Token 鉴权失败 | **401** | 尝试刷新,失败则重新登录 | +| 11002 | Token 已过期 | **401** | 刷新 Token 后重试 | +| 11003 | Token 生成失败 | 200 | 重新登录 | +| 11004 | Token 缺失 | **401** | 跳登录 | +| 12000 | 无权限访问 | **403** | 提示无权操作 | +| 19000 | 服务内部错误 | **500** | "服务繁忙,请稍后重试" | +| 19001 | 数据库错误 | **500** | 同上 | +| 19002 | 缓存错误 | **500** | 同上 | +| 19003 | 下游服务调用失败 | **500** | 同上(AI 服务不可用) | +| 19999 | 未知错误 | **500** | 同上 | + +### 8.2 用户(13) + +| code | 含义 | HTTP | +| --- | --- | --- | +| 20000 | 用户参数错误 | 200 | +| 21000 | 用户名或密码错误 | 200 | +| **21001** | **账号已被禁用** | **200** ⚠️ 非 401,需单独处理 | +| 21002 | 微信登录失败 | 200 | +| 21003 | 微信 Code 无效 | 200 | +| 21004 | 注册暂未开放 | 200 | +| 22000 | 用户不存在 | 200 | +| 23000 | 用户已存在 | 200 | +| 23001 | 邮箱已被注册 | 200 | +| 29000 | 用户创建失败 | 200 | +| 29001 | 用户更新失败 | 200 | +| 29002 | 用户删除失败 | 200 | +| 29003 | 用户查询失败 | 200 | + +### 8.3 上传(10) + +| code | 含义 | HTTP | +| --- | --- | --- | +| 30000 | 上传文件为空 | 200 | +| 30001 | 不支持的文件类型 | 200 | +| 30002 | 文件大小超过限制 | 200 | +| 30004 | 文件不存在 | 200 | +| 30005 | 文件不是公开的 | 200 | +| 30006 | 权限不足 | 200 | +| 30007 | 文件处理中 | 200 | +| 39000 | 文件保存失败 | 200 | +| 39001 | 图片抠图处理失败 | **500** | +| 39002 | 图片分类处理失败 | **500** | + +### 8.4 穿搭(5) + +| code | 含义 | HTTP | +| --- | --- | --- | +| 40000 | 穿搭不存在 | 200 | +| 40001 | 创建穿搭失败 | 200 | +| 40002 | 删除穿搭失败 | 200 | +| 40003 | 更新穿搭失败 | 200 | +| 40004 | 无权操作该穿搭 | 200 | + +### 8.5 衣物(6) + +| code | 含义 | HTTP | +| --- | --- | --- | +| 50000 | 衣物不存在 | 200 | +| 50001 | 衣服已被搭配使用,无法删除 | 200 | +| 50002 | 创建衣物失败 | 200 | +| 50003 | 删除衣物失败 | 200 | +| 50004 | 更新衣物失败 | 200 | +| 50005 | 无权操作该衣物 | 200 | + +### 8.6 商品(5) + +| code | 含义 | HTTP | +| --- | --- | --- | +| 60000 | 商品不存在 | 200 | +| 60001 | 商品已下架 | 200 | +| 69000 | 创建商品失败 | 200 | +| 69001 | 更新商品失败 | 200 | +| 69002 | 删除商品失败 | 200 | + +### 8.7 订单 / 退款(12) + +| code | 含义 | HTTP | +| --- | --- | --- | +| 70000 | 订单不存在 | 200 | +| 70001 | 订单状态不正确 | 200 | +| 70002 | 订单已支付 | 200 | +| 70003 | 订单已取消 | 200 | +| 70004 | 订单已过期 | 200 | +| 70005 | 虚拟支付未开通 | 200 | +| 70006 | 商品尚未发布为虚拟支付道具 | 200 | +| 70007 | 虚拟支付订单不支持取消 | 200 | +| 70008 | iOS 虚拟支付最低 1 元 | 200 | +| 79000 | 创建订单失败 | 200 | +| 79001 | 取消订单失败 | 200 | +| 79002 | 更新订单失败 | 200 | +| 79003 | 创建虚拟支付订单失败 | 200 | +| 71000 | 退款记录不存在 | 200 | +| 71001 | 当前状态不允许退款 | 200 | +| 71002 | 退款金额不合法 | 200 | +| 71003 | 退款申请失败 | 200 | +| 71004 | iOS 订单请通过 App Store 申请退款 | 200 | + +### 8.8 额度 / VIP(9) + +| code | 含义 | HTTP | +| --- | --- | --- | +| **80000** | **额度不足** | 200 | +| 80001 | 额度记录不存在 | 200 | +| 80002 | 广告激励已领取 | 200 | +| 80003 | 无效的激励类型 | 200 | +| 80004 | 广告激励未开启 | 200 | +| 89000 | 额度更新失败 | 200 | +| 81000 | 会员记录不存在 | 200 | +| 81001 | 会员已过期 | 200 | +| 81002 | 会员开通失败 | 200 | + +### 8.9 通知 / 分享 / 社区 / 星座 / AI(10) + +| code | 含义 | HTTP | +| --- | --- | --- | +| 90000 | 通知不存在 | 200 | +| 90100 | 分享不存在 | 200 | +| 90101 | 分享已过期 | 200 | +| 90102 | 分享已取消 | 200 | +| 90200 | 目标内容不存在 | 200 | +| 90201 | 点赞操作失败 | 200 | +| 90202 | 收藏操作失败 | 200 | +| 90300 | 星座运势不存在(生成中,稍后重试) | 200 | +| 90400 | AI 推荐失败 | 200 | +| **90401** | **AI 推荐次数已达上限**(AI 额度不足) | 200 | + +--- + +## 9. 前端联调 FAQ 与检查清单 + +### 9.1 高频问题 + +**Q:接口返回 HTTP 200,但业务是失败的?** +A:正常。除 401/403/429/500 外,所有业务错误都返回 HTTP 200。**必须解析 `code`**。 + +**Q:`21001 账号已被禁用` 为什么不是 401?** +A:它在鉴权中间件最后一步(查用户状态)返回,HTTP 是 200。拦截器里需单独判断 `code === 21001` 并跳封禁页。 + +**Q:刷新 Token 一直报 `10000 缺少旧 access token`?** +A:`/user/refresh-token` **必须同时传 `refresh_token` 和 `access_token`**,只传前者会失败。 + +**Q:上传成功但衣物没有属性?** +A:上传是异步的。用返回的 `clothes_uuid` 轮询 `GET /clothes/:uuid/status`,直到 `4`(成功)再拉详情。 + +**Q:更新衣物颜色没生效?** +A:主颜色的**请求字段名是 `color`**(响应才是 `color_main`)。写成 `color_main` 会被忽略(因为只更新非空字段,且不报错)。 + +**Q:AI 推荐报 `10003 请求过于频繁`?** +A:同一用户 180 秒内只能有一个生成任务(Redis 锁)。前端应禁用按钮并提示"生成中",生成完成后再放开。 + +**Q:AI 推荐扣了额度但用户没保存,能退吗?** +A:不能。额度在**生成时**已消耗,只有生成失败才回退。前端应在生成前明确提示消耗。 + +**Q:支付组件回调 success,但订单还是待支付?** +A:以服务端为准。微信回调是异步的,**轮询 `GET /order` 直到 `order_status=2`**。 + +**Q:退款申请后一直没到账?** +A:退款是**人工审核模式**,接口只登记申请(`status=0`)。前端提示"退款审核中",并允许轮询订单状态到 `5`。 + +**Q:衣柜分享列表里显示"正常",但对方打不开?** +A:过期是**惰性**的。前端需同时判断 `status` 与 `expire_time`。 + +**Q:`GET /outfit` 里混进了 AI 推荐的穿搭?** +A:列表**不过滤 `status=3`(待用户保存)**。前端用 `source` 区分来源,AI 未保存的单独入口展示。 + +**Q:没有"社区广场"列表接口怎么办?** +A:当前确实没有。公开穿搭只能通过 UUID 直接访问详情,或在"我的收藏"中出现。如需广场列表,需后端新增接口。 + +### 9.2 联调检查清单 + +- [ ] 统一封装请求:自动带 `Authorization: Bearer`,统一解析 `code` +- [ ] 实现 401 自动刷新 Token(同时传两个 token),失败则清登录态 +- [ ] 单独处理 `code === 21001`(账号禁用,HTTP 200) +- [ ] 金额展示 `÷100`,时间展示 `×1000` +- [ ] 上传后轮询状态(1~2 秒,最多 60 次),展示处理中/失败态 +- [ ] 支付后轮询订单状态,不依赖支付组件回调 +- [ ] 退款提示"人工审核中" +- [ ] 公开穿搭前提示将消耗 1 次搭配分享额度 +- [ ] AI 推荐前提示将消耗 1 次 AI 额度,且**不保存不退** +- [ ] 点赞/收藏按钮做乐观更新 + 失败回滚(接口幂等,可重试) +- [ ] 通知红点通过轮询 `GET /notification/unread` 刷新(无推送) +- [ ] 不可逆操作(删除衣物/穿搭、取消订单/分享)加二次确认 +- [ ] 列表分页 `page_size` 不超过 50 +- [ ] 排障时请求头自带 `X-Request-ID`(跨域读不到响应头) + +--- + +## 10. 接口调用示例(curl) + +> 示例中的 token、UUID、订单号均为示意值;`BASE` = `http:///api/v1`。 + +### 微信登录 + +```bash +curl -X POST $BASE/user/wxlogin \ + -H "Content-Type: application/json" \ + -d '{"code": "0b3abcxxxxxxxx"}' +``` + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "eyJhbGciOiJIUzI1NiIs...", + "expire_time": 1756742400, + "user_info": { + "username": "微信用户", + "avatar": "", + "sex": 0, + "birth_day": 0, + "phone": "", + "is_new_user": true, + "status": 1, + "ban_reason": "", + "ban_start": 0, + "ban_expire": 0, + "vip_type": 4, + "vip_expire": 1744761600 + } + } +} +``` + +### 刷新 Token + +```bash +curl -X POST $BASE/user/refresh-token \ + -H "Content-Type: application/json" \ + -d '{"refresh_token":"","access_token":"<旧 access_token>"}' +``` + +### 上传衣物(multipart) + +```bash +curl -X POST $BASE/clothes/upload \ + -H "Authorization: Bearer " \ + -F "file=@/path/to/t-shirt.png" +``` + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "clothes_uuid": "c8f2e1a0-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "image_process_status": 3 + } +} +``` + +### 轮询处理状态 + +```bash +curl "$BASE/clothes/c8f2e1a0-xxxx-xxxx-xxxx-xxxxxxxxxxxx/status" \ + -H "Authorization: Bearer " +``` + +```json +{ "code": 0, "msg": "成功", "data": { "image_process_status": 4, "fail_reason": "" } } +``` + +### 修正衣物属性(注意 `color`) + +```bash +curl -X PUT $BASE/clothes/c8f2e1a0-xxxx-xxxx-xxxx-xxxxxxxxxxxx \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"color":"白色","category_main":"上装","name":"我的白T"}' +``` + +### 额度概览 + +```bash +curl $BASE/quota/info -H "Authorization: Bearer " +``` + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "quotas": [ + { "quota_type": 1, "total": 20, "used": 1, "remain": 19 }, + { "quota_type": 2, "total": 3, "used": 0, "remain": 3 }, + { "quota_type": 3, "total": 10, "used": 0, "remain": 10 }, + { "quota_type": 4, "total": 5, "used": 0, "remain": 5 } + ] + } +} +``` + +### AI 穿搭推荐与保存 + +```bash +curl -X POST $BASE/ai-recommend \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"scene_type": 2, "scene": "参加朋友的婚礼"}' +``` + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "outfit_uuid": "of-xxxxxxxxxxxx", + "outfit_name": "婚礼宾客穿搭", + "outfit_desc": "简约大方,正式又不失亲和力", + "scene": "参加朋友的婚礼", + "source": 2, + "status": 3, + "outfit_item": [{ "clothes_uuid": "c8f2e1a0-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }] + } +} +``` + +```bash +curl -X POST $BASE/ai-recommend/save \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"outfit_uuid":"of-xxxxxxxxxxxx"}' +``` + +### 创建订单(微信支付) + +```bash +curl -X POST $BASE/order \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"goods_code": "AI_3"}' +``` + +```json +{ + "code": 0, + "msg": "成功", + "data": { + "order_no": "OD20260907000123", + "pay_amount": 990, + "expire_time": 1756743300, + "pay_params": { + "timeStamp": "1756742400", + "nonceStr": "1a2b3c4d5e6f", + "package": "prepay_id=wx09153000123456", + "signType": "RSA", + "paySign": "xxxxxxxxxxxxxxxx" + } + } +} +``` + +小程序侧: + +```javascript +wx.requestPayment({ + ...pay_params, // timeStamp / nonceStr / package / signType / paySign 原样透传 + success: () => { /* 仅表示拉起成功,仍需轮询 GET /order 确认 order_status=2 */ }, + fail: () => { /* 用户取消或失败 */ } +}) +``` + +### 点赞 / 取消点赞 + +```bash +curl -X POST $BASE/like \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"target_uuid":"of-xxxxxxxxxxxx","action":1}' + +curl "$BASE/like/of-xxxxxxxxxxxx" -H "Authorization: Bearer " +# {"code":0,"msg":"成功","data":{"is_liked":true,"like_count":12}} +``` + +### 创建衣柜分享 + +```bash +curl -X POST $BASE/closet-share \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"share_type":1,"clothes_uuids":["c8f2e1a0-xxxx"],"expire_time":1756828800}' +``` + +### 错误响应示例(额度不足) + +```json +{ "code": 80000, "msg": "额度不足" } +``` + +### 错误响应示例(参数校验) + +```json +{ + "code": 10000, + "msg": "请求参数错误", + "details": ["Key: 'OutfitCreateRequest.OutfitName' Error:Field validation for 'OutfitName' failed on the 'required' tag"] +} +``` + +--- + +> **文档维护说明**:本文档由后端源码(`internal/routers/router.go`、`internal/dto/`、`internal/service/`、`pkg/errcode/`)逐字段核对生成。接口变更时请同步更新本文件;工程视角的部署/配置说明见 `README.md`。 +