From 7155b8b53af06ea7d09581800d4e574da3b268c7 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Tue, 15 Sep 2026 00:26:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=8E=A5=E5=85=A5=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E7=99=BB=E5=BD=95=E5=B9=B6=E5=8E=9F=E7=94=9F=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E5=92=A8=E8=AF=A2=E5=9F=9F=EF=BC=88ECR-049/050?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 小程序可在 Go 上完成微信手机号登录、测评、预约和下单,不再反代 Java。 Co-authored-by: Cursor --- .ai/adr/0008-go-wechat-consult-gateway.md | 29 + .ai/product/feature-spec/account-auth.md | 25 +- .../feature-spec/consult-miniprogram.md | 22 + apps/api/config.example.yaml | 17 + apps/api/internal/config/config.go | 85 ++ apps/api/internal/handler/auth.go | 33 + apps/api/internal/handler/consult.go | 384 ++++++++ apps/api/internal/handler/media.go | 59 ++ apps/api/internal/httpserver/router.go | 18 +- .../integration/consult_native_test.go | 30 + apps/api/internal/javabridge/client.go | 119 +++ apps/api/internal/repository/auth_repo.go | 104 ++- apps/api/internal/service/auth/service.go | 101 +++ apps/api/internal/service/consult/service.go | 818 ++++++++++++++++++ apps/api/internal/wechat/client.go | 84 ++ apps/api/internal/wechat/phone.go | 75 ++ apps/api/internal/wechat/phone_test.go | 47 + apps/api/internal/wechatpay/sign.go | 158 ++++ apps/api/internal/wechatpay/sign_test.go | 17 + .../000058_wechat_auth_gateway.down.sql | 5 + .../000058_wechat_auth_gateway.up.sql | 10 + .../migrations/000059_consult_native.down.sql | 16 + .../migrations/000059_consult_native.up.sql | 259 ++++++ .../BD-2026-049-wechat-auth-gateway.md | 26 + .../BD-2026-050-consult-native.md | 21 + docs/CHANGELOG.md | 5 + docs/CONTRACT_DIFF/ECR-049.yaml | 18 + docs/CONTRACT_DIFF/ECR-050.yaml | 18 + docs/ECR/ECR-049-wechat-auth-gateway.md | 25 + docs/ECR/ECR-050-consult-native.md | 24 + docs/HANDOFF/ECR-049-architect-to-engineer.md | 3 + docs/HANDOFF/ECR-049-engineer-to-reviewer.md | 5 + docs/HANDOFF/ECR-050-architect-to-engineer.md | 3 + docs/HANDOFF/ECR-050-engineer-to-reviewer.md | 4 + .../ECR-049-wechat-auth-gateway.md | 5 + docs/PROJECT_PROFILE.md | 8 +- docs/STATE/ECR-049.md | 7 + docs/STATE/ECR-050.md | 7 + docs/TEST_REPORT/ECR-049.md | 13 + docs/TEST_REPORT/ECR-050.md | 14 + docs/TRACEABILITY.md | 6 +- docs/WAVE0/ECR-049-CANDIDATE_REVIEW.md | 16 + docs/WAVE0/ECR-049_WRITE_AUTHORIZATION.md | 21 + docs/WAVE0/ECR-050_WRITE_AUTHORIZATION.md | 14 + scripts/import_java_consult.py | 262 ++++++ 45 files changed, 3013 insertions(+), 27 deletions(-) create mode 100644 .ai/adr/0008-go-wechat-consult-gateway.md create mode 100644 .ai/product/feature-spec/consult-miniprogram.md create mode 100644 apps/api/internal/handler/consult.go create mode 100644 apps/api/internal/integration/consult_native_test.go create mode 100644 apps/api/internal/javabridge/client.go create mode 100644 apps/api/internal/service/consult/service.go create mode 100644 apps/api/internal/wechat/client.go create mode 100644 apps/api/internal/wechat/phone.go create mode 100644 apps/api/internal/wechat/phone_test.go create mode 100644 apps/api/internal/wechatpay/sign.go create mode 100644 apps/api/internal/wechatpay/sign_test.go create mode 100644 apps/api/migrations/000058_wechat_auth_gateway.down.sql create mode 100644 apps/api/migrations/000058_wechat_auth_gateway.up.sql create mode 100644 apps/api/migrations/000059_consult_native.down.sql create mode 100644 apps/api/migrations/000059_consult_native.up.sql create mode 100644 docs/BACKEND_DESIGN/BD-2026-049-wechat-auth-gateway.md create mode 100644 docs/BACKEND_DESIGN/BD-2026-050-consult-native.md create mode 100644 docs/CONTRACT_DIFF/ECR-049.yaml create mode 100644 docs/CONTRACT_DIFF/ECR-050.yaml create mode 100644 docs/ECR/ECR-049-wechat-auth-gateway.md create mode 100644 docs/ECR/ECR-050-consult-native.md create mode 100644 docs/HANDOFF/ECR-049-architect-to-engineer.md create mode 100644 docs/HANDOFF/ECR-049-engineer-to-reviewer.md create mode 100644 docs/HANDOFF/ECR-050-architect-to-engineer.md create mode 100644 docs/HANDOFF/ECR-050-engineer-to-reviewer.md create mode 100644 docs/PRODUCT_SPEC/ECR-049-wechat-auth-gateway.md create mode 100644 docs/STATE/ECR-049.md create mode 100644 docs/STATE/ECR-050.md create mode 100644 docs/TEST_REPORT/ECR-049.md create mode 100644 docs/TEST_REPORT/ECR-050.md create mode 100644 docs/WAVE0/ECR-049-CANDIDATE_REVIEW.md create mode 100644 docs/WAVE0/ECR-049_WRITE_AUTHORIZATION.md create mode 100644 docs/WAVE0/ECR-050_WRITE_AUTHORIZATION.md create mode 100644 scripts/import_java_consult.py diff --git a/.ai/adr/0008-go-wechat-consult-gateway.md b/.ai/adr/0008-go-wechat-consult-gateway.md new file mode 100644 index 0000000..13dc538 --- /dev/null +++ b/.ai/adr/0008-go-wechat-consult-gateway.md @@ -0,0 +1,29 @@ +# ADR-0008 — Go 为唯一用户后台,咨询域过渡反代 + +- Status: Accepted +- Date: 2026-09-14 +- Tags: identity, miniprogram, consult, gateway + +## Context + +愈心谷 C 端同时打两套后台:咨询/测评在 Java + MySQL,愈心魔方在 Go + PostgreSQL。 +`wx.login` 的 `code` 只能用一次,无法先后换两套 token。小程序需要单一身份与单一请求入口。 + +## Decision + +1. **Go + PostgreSQL 是唯一用户身份源。** 微信 openid / 手机号写在 `users`。 +2. **Java 过渡期为被调方**:Go 用 openid/phone 调内网 `issue-mini-app` 换 Java Redis token,并反代 `/app-api`、`/admin-api`。 +3. **咨询业务表本 ADR 不迁**;支付与档期另开 ECR。 +4. **Admin 登录独立**,不走本决策。 + +## Consequences + +- 小程序只配置 Go 域名(现网经 `/psy/api/` 反代)。 +- 下线 Java 必须另开 ECR,并先迁支付。 +- 历史魔方账密用户按手机号合并到 `wx_openid`。 + +## Alternatives considered + +- 只做 SSO、双库长期并存:否决(用户要求合库合服务)。 +- 以 Java 吸收魔方:否决(栈已锁定 Go + PG)。 +- 客户端双 token:否决(code 单次使用,且拖住前端统一接入)。 diff --git a/.ai/product/feature-spec/account-auth.md b/.ai/product/feature-spec/account-auth.md index cd05c40..24e5bd9 100644 --- a/.ai/product/feature-spec/account-auth.md +++ b/.ai/product/feature-spec/account-auth.md @@ -10,13 +10,13 @@ | 字段 | 内容 | |---|---| -| Name | 账号登录(手机号+密码) | +| Name | 账号登录(小程序微信;H5 过渡账密) | | Purpose | 跨设备持久用户身份;未登录不可生成/查看生日衍生结果 | -| Business Goal | 数据归属清晰;为会员与深度版付费打底 | +| Business Goal | 数据归属清晰;咨询与魔方同一人;为会员与深度版付费打底 | | In | Out | |---|---| -| 注册 / 登录 / 登出 / me | 微信 OAuth、短信 OTP(后续) | +| 小程序微信登录 / 登出 / me | 短信 OTP | | 设备身份绑定到已注册账号 | 游客可看完整报告 | | Bearer Session | 运营 Admin 登录(独立) | @@ -45,7 +45,7 @@ ```text 进入结果页或建档 ↓ -未登录? → /login 注册或登录 +未登录? → 小程序 login-pop(微信)或 H5 /login(过渡账密) ↓ Device 绑定到账号 User ↓ @@ -58,7 +58,8 @@ Device 绑定到账号 User | 路由 | 页面 | |---|---| -| `/login` | LoginPage(注册/登录切换) | +| `/login` | H5 LoginPage(过渡账密;小程序已删除此页) | +| 小程序 `login-pop` | 微信手机号授权(唯一 C 端登录) | | `/mine` | 展示账号手机号尾号 + 退出 | --- @@ -77,8 +78,8 @@ Device 绑定到账号 User | ID | Rule | |---|---| -| R1 | **临时开放:** 任意非空手机号 + 任意密码均可登录;未注册则自动建号写入 DB | -| R2 | 登录/注册统一走 OpenLogin;不校验历史密码,仍会更新 password_hash 记录本次输入 | +| R1 | **小程序:** 微信 `code` + 手机号授权登录;按 `wx_openid` 命中,否则按手机号合并历史账密账号;未命中则建号 | +| R2 | **H5 过渡:** OpenLogin(任意非空手机号+密码)仍可用,避免 H5 断服;小程序不再提供账密页。正式关掉账密须另改本 Spec | | R3 | 登录成功后:签发 session;device_identities.user_id 改绑到账号 | | R4 | 生成/查看档案与报告 API 必须已注册(users.phone 非空)且有效 session 或已绑设备账号 | | R5 | 登出作废 session;设备可再登录其他账号 | @@ -91,7 +92,7 @@ Device 绑定到账号 User ## 8. 数据模型影响 -- `users.phone` UNIQUE · `users.password_hash` · `users.nickname` · `users.avatar_url` +- `users.phone` UNIQUE · `users.wx_openid` UNIQUE · `users.wx_unionid` · `users.java_platform_user_id` · `users.password_hash` · `users.nickname` · `users.avatar_url` - `user_sessions(token, user_id, expires_at)` - 本地文件:`data/avatars/{user_id}.{jpg|png|webp}`(进程相对路径) @@ -101,8 +102,9 @@ Device 绑定到账号 User | Method | Path | 说明 | |---|---|---| -| POST | `/api/v1/auth/register` | 注册(可带 nickname) | -| POST | `/api/v1/auth/login` | 登录 | +| POST | `/api/v1/auth/wechat` | 小程序微信登录 `{ code, encryptedData, iv }` | +| POST | `/api/v1/auth/register` | H5 过渡注册(可带 nickname) | +| POST | `/api/v1/auth/login` | H5 过渡登录 | | POST | `/api/v1/auth/logout` | 登出 | | GET | `/api/v1/auth/me` | 当前账号(含 avatar_url) | | PATCH | `/api/v1/auth/me` | 更新昵称 `{ nickname }` | @@ -158,4 +160,5 @@ Device 绑定到账号 User ## 13. AI 开发前检查 - [x] lexicon:登录/注册/账号 -- [x] 不碰 Admin Auth +- [x] 不碰 Admin Auth +- [x] 咨询域见 Spec `consult-miniprogram`(ECR-050) diff --git a/.ai/product/feature-spec/consult-miniprogram.md b/.ai/product/feature-spec/consult-miniprogram.md new file mode 100644 index 0000000..de6b126 --- /dev/null +++ b/.ai/product/feature-spec/consult-miniprogram.md @@ -0,0 +1,22 @@ +# Feature Spec: 小程序咨询域(原生 Go) + +> Status: `Active` · ECR-050 + +## 1. 功能定义 + +小程序首页/咨询/测评/订单/我的资料不再打 Java。Go 用 PostgreSQL 提供与原 `/psychic/*` 相同的字段合同。 + +## 2. Business Rules + +| ID | Rule | +|---|---| +| C1 | 未登录可看 banner、资讯、测评列表、咨询师、档期、协议 | +| C2 | 登录后才能交卷、关注、下单、改资料、反馈 | +| C3 | 下单锁时段;取消未支付订单释放时段 | +| C4 | 金额单位:分;支付 openid = `users.wx_openid` | +| C5 | 测评仅单选计分,按分数区间匹配结果 | +| C6 | 协议路径保持历史拼写 `procotol` | + +## 3. API + +全部挂在 `/api/v1/psychic/*`,字段与原 Java App VO 一致(见 ECR-050 BD)。 diff --git a/apps/api/config.example.yaml b/apps/api/config.example.yaml index a533ee3..fdc86d5 100644 --- a/apps/api/config.example.yaml +++ b/apps/api/config.example.yaml @@ -24,6 +24,23 @@ admin: bootstrap_username: admin bootstrap_password: "change-me" # override in config.local.yaml; never commit secrets +# Mini-program WeChat login (never commit real secrets) +wechat: + app_id: "" + secret: "" + +# Legacy Java (ECR-049, unused after ECR-050 native consult) +java: + base_url: "" + internal_key: "" + +# WeChat JSAPI pay. Empty = mock pay params (dev). +pay: + app_id: "" + mch_id: "" + api_key: "" + notify_url: "https://h5.yuxingu.com.cn/psy/api/v1/psychic/pay/notify" + # jwt: # secret: "" # payment: diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 4376cd8..e82344f 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -19,6 +19,29 @@ type Config struct { AppEnv string DeepSeek DeepSeekConfig Admin AdminConfig + WeChat WeChatConfig + Java JavaConfig + Pay PayConfig +} + +// PayConfig is WeChat JSAPI merchant settings. +type PayConfig struct { + AppID string + MchID string + APIKey string + NotifyURL string +} + +// WeChatConfig is mini-program jscode2session credentials. +type WeChatConfig struct { + AppID string + Secret string +} + +// JavaConfig is the consult stack bridge (internal issue + proxy). +type JavaConfig struct { + BaseURL string + InternalKey string } // AdminConfig for ops console bootstrap (ECR-006). @@ -58,6 +81,20 @@ type fileConfig struct { BootstrapUsername string `yaml:"bootstrap_username"` BootstrapPassword string `yaml:"bootstrap_password"` } `yaml:"admin"` + WeChat struct { + AppID string `yaml:"app_id"` + Secret string `yaml:"secret"` + } `yaml:"wechat"` + Java struct { + BaseURL string `yaml:"base_url"` + InternalKey string `yaml:"internal_key"` + } `yaml:"java"` + Pay struct { + AppID string `yaml:"app_id"` + MchID string `yaml:"mch_id"` + APIKey string `yaml:"api_key"` + NotifyURL string `yaml:"notify_url"` + } `yaml:"pay"` } // Load reads config.local.yaml (or CONFIG_PATH), then applies env overrides. @@ -140,6 +177,30 @@ func mergeFile(cfg *Config, path string) error { if f.Admin.BootstrapPassword != "" { cfg.Admin.BootstrapPassword = f.Admin.BootstrapPassword } + if f.WeChat.AppID != "" { + cfg.WeChat.AppID = f.WeChat.AppID + } + if f.WeChat.Secret != "" { + cfg.WeChat.Secret = f.WeChat.Secret + } + if f.Java.BaseURL != "" { + cfg.Java.BaseURL = strings.TrimRight(f.Java.BaseURL, "/") + } + if f.Java.InternalKey != "" { + cfg.Java.InternalKey = f.Java.InternalKey + } + if f.Pay.AppID != "" { + cfg.Pay.AppID = f.Pay.AppID + } + if f.Pay.MchID != "" { + cfg.Pay.MchID = f.Pay.MchID + } + if f.Pay.APIKey != "" { + cfg.Pay.APIKey = f.Pay.APIKey + } + if f.Pay.NotifyURL != "" { + cfg.Pay.NotifyURL = f.Pay.NotifyURL + } return nil } @@ -207,6 +268,30 @@ func applyEnv(cfg *Config) { if v := os.Getenv("ADMIN_BOOTSTRAP_PASSWORD"); v != "" { cfg.Admin.BootstrapPassword = v } + if v := os.Getenv("WECHAT_MINI_APP_ID"); v != "" { + cfg.WeChat.AppID = v + } + if v := os.Getenv("WECHAT_MINI_APP_SECRET"); v != "" { + cfg.WeChat.Secret = v + } + if v := os.Getenv("JAVA_CONSULT_BASE_URL"); v != "" { + cfg.Java.BaseURL = strings.TrimRight(v, "/") + } + if v := os.Getenv("JAVA_INTERNAL_KEY"); v != "" { + cfg.Java.InternalKey = v + } + if v := os.Getenv("PAY_APP_ID"); v != "" { + cfg.Pay.AppID = v + } + if v := os.Getenv("PAY_MCH_ID"); v != "" { + cfg.Pay.MchID = v + } + if v := os.Getenv("PAY_API_KEY"); v != "" { + cfg.Pay.APIKey = v + } + if v := os.Getenv("PAY_NOTIFY_URL"); v != "" { + cfg.Pay.NotifyURL = v + } } // Enabled reports whether DeepSeek can be called. diff --git a/apps/api/internal/handler/auth.go b/apps/api/internal/handler/auth.go index eca8149..c2a8f10 100644 --- a/apps/api/internal/handler/auth.go +++ b/apps/api/internal/handler/auth.go @@ -22,6 +22,7 @@ func (h *AuthHandler) Register(api *gin.RouterGroup) { g := api.Group("/auth") g.POST("/register", h.RegisterAccount) g.POST("/login", h.Login) + g.POST("/wechat", h.WeChat) g.POST("/logout", h.Logout) g.GET("/me", h.Me) g.PATCH("/me", h.PatchMe) @@ -34,6 +35,12 @@ type authBody struct { Nickname string `json:"nickname"` } +type wechatBody struct { + Code string `json:"code"` + EncryptedData string `json:"encryptedData"` + IV string `json:"iv"` +} + // RegisterAccount handles POST /auth/register (DeviceAuth required on group). func (h *AuthHandler) RegisterAccount(c *gin.Context) { userID, ok := middleware.UserIDFromContext(c) @@ -88,6 +95,32 @@ func (h *AuthHandler) Login(c *gin.Context) { response.OK(c, res) } +// WeChat handles POST /auth/wechat (mini-program phone login). +func (h *AuthHandler) WeChat(c *gin.Context) { + userID, ok := middleware.UserIDFromContext(c) + if !ok { + response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized") + return + } + var body wechatBody + if err := c.ShouldBindJSON(&body); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + deviceKey := c.GetHeader(middleware.DeviceKeyHeader) + res, err := h.Svc.WeChatLogin(c.Request.Context(), userID, deviceKey, body.Code, body.EncryptedData, body.IV) + if err != nil { + if errors.Is(err, auth.ErrAccountRestricted) { + response.Fail(c, http.StatusUnauthorized, 40113, err.Error()) + return + } + response.Fail(c, http.StatusBadRequest, 40115, err.Error()) + return + } + c.Set(string(middleware.UserIDKey), res.User.ID) + response.OK(c, res) +} + // Logout handles POST /auth/logout. func (h *AuthHandler) Logout(c *gin.Context) { tok := bearerToken(c) diff --git a/apps/api/internal/handler/consult.go b/apps/api/internal/handler/consult.go new file mode 100644 index 0000000..05a66f4 --- /dev/null +++ b/apps/api/internal/handler/consult.go @@ -0,0 +1,384 @@ +package handler + +import ( + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/yuxingu/digital-psychology/apps/api/internal/middleware" + "github.com/yuxingu/digital-psychology/apps/api/internal/service/consult" + "github.com/yuxingu/digital-psychology/apps/api/pkg/response" +) + +// ConsultHandler serves /api/v1/psychic/* (ECR-050). +type ConsultHandler struct { + Svc *consult.Service +} + +func (h *ConsultHandler) Register(api *gin.RouterGroup) { + g := api.Group("/psychic") + g.POST("/banner/all", h.banners) + g.GET("/news/page", h.newsPage) + g.GET("/news/get", h.newsGet) + g.POST("/procotol/getByCode", h.protocol) + g.POST("/study/list", h.studyList) + g.POST("/study/getSingleDetail", h.studyDetail) + g.POST("/user-choice/save", h.choiceSave) + g.POST("/user-choice/getResult", h.choiceResult) + g.POST("/user-choice/my-test", h.myTest) + g.GET("/doctor-info/business-scope-list", h.scopes) + g.GET("/doctor-info/page", h.doctorPage) + g.GET("/doctor-info/get", h.doctorGet) + g.POST("/doctor-info/focus", h.focusOn) + g.POST("/doctor-info/cancel-focus", h.focusOff) + g.GET("/doctor-info/get-show-info", h.showInfo) + g.POST("/appointment/remain-list", h.remain) + g.POST("/appointment/date-detail-list", h.dateDetail) + g.POST("/order/create", h.orderCreate) + g.GET("/order/get-pay-param", h.payParam) + g.POST("/order/order-list", h.orderList) + g.POST("/order/order-detail", h.orderDetail) + g.POST("/order/cancel", h.orderCancel) + g.POST("/order/delete-order", h.orderDelete) + g.POST("/pay/createOrder", h.payDemo) + g.POST("/pay/refund", h.payRefund) + g.POST("/pay/notify", h.payNotify) + g.GET("/platform-user/getSelfInfo", h.selfInfo) + g.POST("/platform-user/update", h.updateSelf) + g.GET("/platform-user/not-read-num", h.notRead) + g.POST("/platform-user/focus-to-read", h.focusRead) + g.POST("/platform-user/order-to-read", h.orderRead) + g.POST("/platform-user/focus-all", h.focusAll) + g.POST("/platform-user/user-feedback", h.feedback) + g.POST("/platform-user/feedback-flag", h.feedbackFlag) +} + +func (h *ConsultHandler) banners(c *gin.Context) { + data, err := h.Svc.Banners(c.Request.Context()) + h.ok(c, data, err) +} +func (h *ConsultHandler) newsPage(c *gin.Context) { + data, err := h.Svc.NewsPage(c.Request.Context(), qInt(c, "type", 0), qInt(c, "showMain", -1), qInt(c, "pageNo", 1), qInt(c, "pageSize", 20)) + h.ok(c, data, err) +} + +func (h *ConsultHandler) ok(c *gin.Context, data any, err error) { + if err != nil { + response.Fail(c, http.StatusBadRequest, 40001, err.Error()) + return + } + response.OK(c, data) +} + +func (h *ConsultHandler) newsGet(c *gin.Context) { + data, err := h.Svc.NewsGet(c.Request.Context(), qInt64(c, "id")) + h.ok(c, data, err) +} +func (h *ConsultHandler) protocol(c *gin.Context) { + data, err := h.Svc.ProtocolByCode(c.Request.Context(), c.Query("code")) + h.ok(c, data, err) +} +func (h *ConsultHandler) studyList(c *gin.Context) { + data, err := h.Svc.TestList(c.Request.Context(), qInt(c, "showMain", -1)) + h.ok(c, data, err) +} +func (h *ConsultHandler) studyDetail(c *gin.Context) { + data, err := h.Svc.TestDetail(c.Request.Context(), qInt64(c, "studyId")) + h.ok(c, data, err) +} +func (h *ConsultHandler) choiceSave(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + var in consult.ChoiceIn + if err := c.ShouldBindJSON(&in); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + data, err := h.Svc.SaveChoice(c.Request.Context(), uid, in) + h.ok(c, data, err) +} +func (h *ConsultHandler) choiceResult(c *gin.Context) { + data, err := h.Svc.GetResult(c.Request.Context(), qInt64(c, "resultId")) + h.ok(c, data, err) +} +func (h *ConsultHandler) myTest(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + var p struct { + PageNo int `json:"pageNo"` + PageSize int `json:"pageSize"` + } + _ = c.ShouldBindJSON(&p) + data, err := h.Svc.MyTests(c.Request.Context(), uid, p.PageNo, p.PageSize) + h.ok(c, data, err) +} +func (h *ConsultHandler) scopes(c *gin.Context) { + data, err := h.Svc.Scopes(c.Request.Context()) + h.ok(c, data, err) +} +func (h *ConsultHandler) doctorPage(c *gin.Context) { + data, err := h.Svc.DoctorPage(c.Request.Context(), c.Query("businessScope"), qInt(c, "isTop", -1), qInt(c, "pageNo", 1), qInt(c, "pageSize", 20)) + h.ok(c, data, err) +} +func (h *ConsultHandler) doctorGet(c *gin.Context) { + uid, _ := middleware.UserIDFromContext(c) + data, err := h.Svc.DoctorGet(c.Request.Context(), qInt64(c, "id"), uid) + h.ok(c, data, err) +} +func (h *ConsultHandler) focusOn(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + if err := h.Svc.SetFocus(c.Request.Context(), uid, qInt64(c, "id"), true); err != nil { + response.Fail(c, http.StatusBadRequest, 40001, err.Error()) + return + } + response.OK(c, true) +} +func (h *ConsultHandler) focusOff(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + if err := h.Svc.SetFocus(c.Request.Context(), uid, qInt64(c, "id"), false); err != nil { + response.Fail(c, http.StatusBadRequest, 40001, err.Error()) + return + } + response.OK(c, true) +} +func (h *ConsultHandler) showInfo(c *gin.Context) { + uid, _ := middleware.UserIDFromContext(c) + data, err := h.Svc.ShowInfo(c.Request.Context(), qInt64(c, "id"), uid) + h.ok(c, data, err) +} +func (h *ConsultHandler) remain(c *gin.Context) { + var in struct { + DoctorID int64 `json:"doctorId"` + } + _ = c.ShouldBindJSON(&in) + data, err := h.Svc.RemainList(c.Request.Context(), in.DoctorID) + h.ok(c, data, err) +} +func (h *ConsultHandler) dateDetail(c *gin.Context) { + var in struct { + DoctorID int64 `json:"doctorId"` + } + _ = c.ShouldBindJSON(&in) + data, err := h.Svc.DateDetail(c.Request.Context(), in.DoctorID) + h.ok(c, data, err) +} +func (h *ConsultHandler) orderCreate(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + var in consult.OrderIn + if err := c.ShouldBindJSON(&in); err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "invalid request") + return + } + data, err := h.Svc.CreateOrder(c.Request.Context(), uid, in) + h.ok(c, data, err) +} +func (h *ConsultHandler) payParam(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + data, err := h.Svc.PayParam(c.Request.Context(), uid, c.Query("orderSn")) + h.ok(c, data, err) +} +func (h *ConsultHandler) orderList(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + var p struct { + PageNo int `json:"pageNo"` + PageSize int `json:"pageSize"` + } + _ = c.ShouldBindJSON(&p) + data, err := h.Svc.OrderList(c.Request.Context(), uid, p.PageNo, p.PageSize) + h.ok(c, data, err) +} +func (h *ConsultHandler) orderDetail(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + data, err := h.Svc.OrderDetail(c.Request.Context(), uid, qInt64(c, "orderId")) + h.ok(c, data, err) +} +func (h *ConsultHandler) orderCancel(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + if err := h.Svc.CancelOrder(c.Request.Context(), uid, qInt64(c, "orderId")); err != nil { + response.Fail(c, http.StatusBadRequest, 40001, err.Error()) + return + } + response.OK(c, true) +} +func (h *ConsultHandler) orderDelete(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + if err := h.Svc.DeleteOrder(c.Request.Context(), uid, qInt64(c, "orderId")); err != nil { + response.Fail(c, http.StatusBadRequest, 40001, err.Error()) + return + } + response.OK(c, true) +} +func (h *ConsultHandler) payDemo(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + data, err := h.Svc.DemoPay(c.Request.Context(), uid) + h.ok(c, data, err) +} +func (h *ConsultHandler) payRefund(c *gin.Context) { + response.OK(c, true) +} +func (h *ConsultHandler) payNotify(c *gin.Context) { + raw, _ := c.GetRawData() + sn := extractXML(string(raw), "out_trade_no") + if sn != "" { + _ = h.Svc.MarkPaid(c.Request.Context(), sn) + } + c.String(http.StatusOK, "") +} +func (h *ConsultHandler) selfInfo(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + data, err := h.Svc.SelfInfo(c.Request.Context(), uid) + h.ok(c, data, err) +} +func (h *ConsultHandler) updateSelf(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + var in struct { + NickName string `json:"nickName"` + AvatarUrl string `json:"avatarUrl"` + StayPeriod string `json:"stayPeriod"` + } + _ = c.ShouldBindJSON(&in) + if err := h.Svc.UpdateSelf(c.Request.Context(), uid, in.NickName, in.AvatarUrl, in.StayPeriod); err != nil { + response.Fail(c, http.StatusBadRequest, 40001, err.Error()) + return + } + response.OK(c, true) +} +func (h *ConsultHandler) notRead(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + data, err := h.Svc.NotRead(c.Request.Context(), uid) + h.ok(c, data, err) +} +func (h *ConsultHandler) focusRead(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + _ = h.Svc.FocusToRead(c.Request.Context(), uid) + response.OK(c, true) +} +func (h *ConsultHandler) orderRead(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + _ = h.Svc.OrderToRead(c.Request.Context(), uid) + response.OK(c, true) +} +func (h *ConsultHandler) focusAll(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + data, err := h.Svc.FocusAll(c.Request.Context(), uid) + h.ok(c, data, err) +} +func (h *ConsultHandler) feedback(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + var in struct { + ContentText string `json:"contentText"` + } + _ = c.ShouldBindJSON(&in) + if err := h.Svc.Feedback(c.Request.Context(), uid, in.ContentText); err != nil { + response.Fail(c, http.StatusBadRequest, 40001, err.Error()) + return + } + response.OK(c, true) +} +func (h *ConsultHandler) feedbackFlag(c *gin.Context) { + uid, ok := h.mustUser(c) + if !ok { + return + } + data, err := h.Svc.FeedbackFlag(c.Request.Context(), uid) + h.ok(c, data, err) +} + +func (h *ConsultHandler) mustUser(c *gin.Context) (uuid.UUID, bool) { + uid, ok := middleware.UserIDFromContext(c) + if !ok || !h.Svc.Registered(c.Request.Context(), uid) { + response.Fail(c, http.StatusUnauthorized, 40112, "请先登录后再使用") + return uuid.Nil, false + } + return uid, true +} + +func qInt(c *gin.Context, key string, def int) int { + s := c.Query(key) + if s == "" { + return def + } + n, err := strconv.Atoi(s) + if err != nil { + return def + } + return n +} + +func qInt64(c *gin.Context, key string) int64 { + n, _ := strconv.ParseInt(c.Query(key), 10, 64) + return n +} + +func extractXML(s, tag string) string { + open, close := "<"+tag+">", "" + i := strings.Index(s, open) + if i < 0 { + open, close = "<"+tag+">" + i = strings.Index(s, open) + if i < 0 { + return "" + } + } + i += len(open) + j := strings.Index(s[i:], close) + if j < 0 { + return "" + } + return s[i : i+j] +} diff --git a/apps/api/internal/handler/media.go b/apps/api/internal/handler/media.go index 3ac9196..59eb5b5 100644 --- a/apps/api/internal/handler/media.go +++ b/apps/api/internal/handler/media.go @@ -1,12 +1,18 @@ package handler import ( + "crypto/rand" + "encoding/hex" + "io" "net/http" + "os" "path/filepath" + "strings" "github.com/gin-gonic/gin" "github.com/yuxingu/digital-psychology/apps/api/internal/avatar" + "github.com/yuxingu/digital-psychology/apps/api/pkg/response" ) // MediaHandler serves uploaded media files. @@ -17,6 +23,8 @@ type MediaHandler struct { // Register mounts public media routes. func (h *MediaHandler) Register(api *gin.RouterGroup) { api.GET("/media/avatars/:file", h.ServeAvatar) + api.GET("/media/uploads/:file", h.ServeUpload) + api.POST("/upload", h.Upload) } // ServeAvatar streams a stored avatar image. @@ -34,3 +42,54 @@ func (h *MediaHandler) ServeAvatar(c *gin.Context) { c.File(full) _ = filepath.Ext(full) } + +func (h *MediaHandler) uploadDir() string { + return "data/uploads" +} + +func (h *MediaHandler) ServeUpload(c *gin.Context) { + name := filepath.Base(c.Param("file")) + full := filepath.Join(h.uploadDir(), name) + if _, err := os.Stat(full); err != nil { + c.Status(http.StatusNotFound) + return + } + c.File(full) +} + +func (h *MediaHandler) Upload(c *gin.Context) { + fh, err := c.FormFile("file") + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "请选择文件") + return + } + src, err := fh.Open() + if err != nil { + response.Fail(c, http.StatusBadRequest, 10000, "读取失败") + return + } + defer src.Close() + ext := strings.ToLower(filepath.Ext(fh.Filename)) + if ext == "" { + ext = ".jpg" + } + var b [8]byte + _, _ = rand.Read(b[:]) + name := hex.EncodeToString(b[:]) + ext + if err := os.MkdirAll(h.uploadDir(), 0o755); err != nil { + response.Fail(c, http.StatusInternalServerError, 50001, "保存失败") + return + } + dstPath := filepath.Join(h.uploadDir(), name) + dst, err := os.Create(dstPath) + if err != nil { + response.Fail(c, http.StatusInternalServerError, 50001, "保存失败") + return + } + defer dst.Close() + if _, err := io.Copy(dst, src); err != nil { + response.Fail(c, http.StatusInternalServerError, 50001, "保存失败") + return + } + response.OK(c, "/api/v1/media/uploads/"+name) +} diff --git a/apps/api/internal/httpserver/router.go b/apps/api/internal/httpserver/router.go index a5042b4..ae43099 100644 --- a/apps/api/internal/httpserver/router.go +++ b/apps/api/internal/httpserver/router.go @@ -19,6 +19,7 @@ import ( authsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/auth" "github.com/yuxingu/digital-psychology/apps/api/internal/service/bootstrap" companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion" + consultsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/consult" homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home" imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard" "github.com/yuxingu/digital-psychology/apps/api/internal/service/membership" @@ -26,6 +27,8 @@ import ( "github.com/yuxingu/digital-psychology/apps/api/internal/service/relation" "github.com/yuxingu/digital-psychology/apps/api/internal/service/report" "github.com/yuxingu/digital-psychology/apps/api/internal/service/scale" + "github.com/yuxingu/digital-psychology/apps/api/internal/wechat" + "github.com/yuxingu/digital-psychology/apps/api/internal/wechatpay" "github.com/yuxingu/digital-psychology/apps/api/pkg/response" ) @@ -73,7 +76,18 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine { adminSvc := &adminsvc.Service{ Repo: adminRepo, Reports: reportRepo, Home: homeSvc, Scales: scaleRepo, } - authSvc := &authsvc.Service{Repo: authRepo, AvatarDir: "data/avatars"} + authSvc := &authsvc.Service{ + Repo: authRepo, + AvatarDir: "data/avatars", + WeChat: &wechat.APIClient{AppID: cfg.WeChat.AppID, Secret: cfg.WeChat.Secret}, + WeChatApp: cfg.WeChat.AppID, + } + consultSvc := &consultsvc.Service{ + Pool: pool, + Pay: wechatpay.Config{ + AppID: cfg.Pay.AppID, MchID: cfg.Pay.MchID, APIKey: cfg.Pay.APIKey, NotifyURL: cfg.Pay.NotifyURL, + }, + } if err := adminSvc.EnsureBootstrap(context.Background(), adminsvc.BootstrapConfig{ Username: cfg.Admin.BootstrapUsername, Password: cfg.Admin.BootstrapPassword, @@ -87,6 +101,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine { c.Header("Access-Control-Expose-Headers", "X-Device-Key, X-Request-Id") c.Next() }) + r.Static("/yxg-mp", "data/yxg-mp") api := r.Group("/api/v1") handler.NewHealthHandler().Register(api) @@ -99,6 +114,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine { authed := api.Group("") authed.Use(middleware.DeviceAuth(pool)) (&handler.AuthHandler{Svc: authSvc}).Register(authed) + (&handler.ConsultHandler{Svc: consultSvc}).Register(authed) (&handler.AnalyticsHandler{Svc: analyticsSvc}).Register(authed) (&handler.HomeHandler{Svc: homeSvc}).Register(authed) (&handler.StarConfigPublicHandler{Repo: adminRepo}).Register(authed) diff --git a/apps/api/internal/integration/consult_native_test.go b/apps/api/internal/integration/consult_native_test.go new file mode 100644 index 0000000..f2f61db --- /dev/null +++ b/apps/api/internal/integration/consult_native_test.go @@ -0,0 +1,30 @@ +package integration_test + +import ( + "net/http" + "testing" +) + +func TestConsultNativePublic(t *testing.T) { + r, _ := setupAPI(t) + env, _ := doJSON(t, r, http.MethodPost, "/api/v1/psychic/banner/all", nil, "") + if env.Code != 0 { + t.Fatalf("banner code %d", env.Code) + } + env, _ = doJSON(t, r, http.MethodGet, "/api/v1/psychic/news/page?type=1&showMain=1&pageNo=1&pageSize=10", nil, "") + if env.Code != 0 { + t.Fatalf("news code %d", env.Code) + } + env, _ = doJSON(t, r, http.MethodPost, "/api/v1/psychic/study/list?showMain=1", nil, "") + if env.Code != 0 { + t.Fatalf("study code %d", env.Code) + } + env, _ = doJSON(t, r, http.MethodGet, "/api/v1/psychic/doctor-info/page?pageNo=1&pageSize=10&isTop=1", nil, "") + if env.Code != 0 { + t.Fatalf("doctor page code %d", env.Code) + } + env, _ = doJSON(t, r, http.MethodPost, "/api/v1/psychic/procotol/getByCode?code=consultation_appointment_agreement", nil, "") + if env.Code != 0 { + t.Fatalf("protocol code %d", env.Code) + } +} diff --git a/apps/api/internal/javabridge/client.go b/apps/api/internal/javabridge/client.go new file mode 100644 index 0000000..b1ffb9b --- /dev/null +++ b/apps/api/internal/javabridge/client.go @@ -0,0 +1,119 @@ +package javabridge + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Client talks to the Java consult stack (internal issue + logout). +type Client struct { + BaseURL string + InternalKey string + HTTP *http.Client +} + +// Token is a Java Redis access token. +type Token struct { + AccessToken string + MemberID int64 +} + +type issueBody struct { + OpenID string `json:"openid"` + UnionID string `json:"unionId"` + Phone string `json:"phone"` +} + +type javaEnvelope struct { + Success bool `json:"success"` + Code any `json:"code"` + Info string `json:"info"` + Msg string `json:"msg"` + Data struct { + AccessToken string `json:"accessToken"` + MemberID int64 `json:"memberId"` + } `json:"data"` +} + +// Enabled reports whether Java bridging is configured. +func (c *Client) Enabled() bool { + return c != nil && strings.TrimSpace(c.BaseURL) != "" && strings.TrimSpace(c.InternalKey) != "" +} + +// IssueMiniApp asks Java to register-or-find mini_app account and mint a Redis token. +func (c *Client) IssueMiniApp(ctx context.Context, openid, unionID, phone string) (*Token, error) { + if !c.Enabled() { + return nil, errors.New("咨询后台未配置") + } + raw, _ := json.Marshal(issueBody{OpenID: openid, UnionID: unionID, Phone: phone}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL, "/")+"/app-api/oauth/internal/issue-mini-app", bytes.NewReader(raw)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Internal-Key", c.InternalKey) + cli := c.http() + resp, err := cli.Do(req) + if err != nil { + return nil, errors.New("咨询账号同步失败") + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + var env javaEnvelope + if err := json.Unmarshal(body, &env); err != nil { + return nil, errors.New("咨询账号同步失败") + } + codeOK := env.Success || fmt.Sprint(env.Code) == "0" || fmt.Sprint(env.Code) == "200" + if resp.StatusCode >= 300 || (!codeOK && env.Data.AccessToken == "") { + msg := env.Info + if msg == "" { + msg = env.Msg + } + if msg == "" { + msg = "咨询账号同步失败" + } + return nil, errors.New(msg) + } + if env.Data.AccessToken == "" { + return nil, errors.New("咨询账号同步失败") + } + return &Token{AccessToken: env.Data.AccessToken, MemberID: env.Data.MemberID}, nil +} + +// Logout revokes a Java access token (best effort). +func (c *Client) Logout(ctx context.Context, accessToken string) { + if !c.Enabled() || strings.TrimSpace(accessToken) == "" { + return + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL, "/")+"/app-api/oauth/app/logout", nil) + if err != nil { + return + } + req.Header.Set("Authorization", "Bearer "+accessToken) + resp, err := c.http().Do(req) + if err == nil && resp != nil { + _ = resp.Body.Close() + } +} + +func (c *Client) http() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return &http.Client{Timeout: 20 * time.Second} +} + +// HTTPDo relays an already-built request (consult proxy). +func (c *Client) HTTPDo(req *http.Request) (*http.Response, error) { + if c == nil { + return nil, errors.New("咨询服务未配置") + } + return c.http().Do(req) +} diff --git a/apps/api/internal/repository/auth_repo.go b/apps/api/internal/repository/auth_repo.go index cb466d9..567aaa5 100644 --- a/apps/api/internal/repository/auth_repo.go +++ b/apps/api/internal/repository/auth_repo.go @@ -17,22 +17,27 @@ type AuthRepo struct { // AccountRow is a registered user snapshot. type AccountRow struct { - ID uuid.UUID - Phone string - PasswordHash string - Nickname string - AvatarURL string - Status string + ID uuid.UUID + Phone string + PasswordHash string + Nickname string + AvatarURL string + Status string + WxOpenID string + WxUnionID string + JavaPlatformUserID int64 } // GetByPhone loads a registered user by phone. func (r *AuthRepo) GetByPhone(ctx context.Context, phone string) (*AccountRow, error) { row := &AccountRow{} err := r.Pool.QueryRow(ctx, ` - SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status + SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status, + COALESCE(wx_openid,''), COALESCE(wx_unionid,''), COALESCE(java_platform_user_id,0) FROM users WHERE phone=$1 AND deleted_at IS NULL`, phone, - ).Scan(&row.ID, &row.Phone, &row.PasswordHash, &row.Nickname, &row.AvatarURL, &row.Status) + ).Scan(&row.ID, &row.Phone, &row.PasswordHash, &row.Nickname, &row.AvatarURL, &row.Status, + &row.WxOpenID, &row.WxUnionID, &row.JavaPlatformUserID) if errors.Is(err, pgx.ErrNoRows) { return nil, err } @@ -47,9 +52,11 @@ func (r *AuthRepo) GetAccount(ctx context.Context, userID uuid.UUID) (*AccountRo row := &AccountRow{} var phone, hash *string err := r.Pool.QueryRow(ctx, ` - SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status + SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status, + COALESCE(wx_openid,''), COALESCE(wx_unionid,''), COALESCE(java_platform_user_id,0) FROM users WHERE id=$1 AND deleted_at IS NULL`, userID, - ).Scan(&row.ID, &phone, &hash, &row.Nickname, &row.AvatarURL, &row.Status) + ).Scan(&row.ID, &phone, &hash, &row.Nickname, &row.AvatarURL, &row.Status, + &row.WxOpenID, &row.WxUnionID, &row.JavaPlatformUserID) if err != nil { return nil, err } @@ -179,6 +186,83 @@ func (r *AuthRepo) RevokeSession(ctx context.Context, token string) error { return err } +// GetByWxOpenid loads a user by WeChat openid. +func (r *AuthRepo) GetByWxOpenid(ctx context.Context, openid string) (*AccountRow, error) { + row := &AccountRow{} + err := r.Pool.QueryRow(ctx, ` + SELECT id, phone, password_hash, COALESCE(nickname,''), COALESCE(avatar_url,''), status, + COALESCE(wx_openid,''), COALESCE(wx_unionid,''), COALESCE(java_platform_user_id,0) + FROM users + WHERE wx_openid=$1 AND deleted_at IS NULL`, openid, + ).Scan(&row.ID, &row.Phone, &row.PasswordHash, &row.Nickname, &row.AvatarURL, &row.Status, + &row.WxOpenID, &row.WxUnionID, &row.JavaPlatformUserID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + if err != nil { + return nil, err + } + return row, nil +} + +// BindWeChat writes openid/unionid (and optional phone) onto a user. +func (r *AuthRepo) BindWeChat(ctx context.Context, userID uuid.UUID, openid, unionID, phone string) error { + _, err := r.Pool.Exec(ctx, ` + UPDATE users + SET wx_openid=$2, + wx_unionid=COALESCE(NULLIF($3,''), wx_unionid), + phone=COALESCE(NULLIF($4,''), phone), + updated_at=now() + WHERE id=$1 AND deleted_at IS NULL`, + userID, openid, unionID, phone, + ) + return err +} + +// SetJavaPlatformUserID stores the Java t_platform_user.id. +func (r *AuthRepo) SetJavaPlatformUserID(ctx context.Context, userID uuid.UUID, javaID int64) error { + _, err := r.Pool.Exec(ctx, ` + UPDATE users SET java_platform_user_id=$2, updated_at=now() + WHERE id=$1 AND deleted_at IS NULL`, userID, javaID) + return err +} + +// SetSessionJavaToken stores the Java Redis token on a Go session. +func (r *AuthRepo) SetSessionJavaToken(ctx context.Context, goToken, javaToken string) error { + _, err := r.Pool.Exec(ctx, ` + UPDATE user_sessions SET java_access_token=$2 + WHERE token=$1 AND revoked_at IS NULL`, goToken, javaToken) + return err +} + +// JavaTokenByGoToken returns the cached Java token for a live Go session. +func (r *AuthRepo) JavaTokenByGoToken(ctx context.Context, goToken string) (string, error) { + var tok *string + err := r.Pool.QueryRow(ctx, ` + SELECT java_access_token FROM user_sessions + WHERE token=$1 AND revoked_at IS NULL AND expires_at > now()`, goToken, + ).Scan(&tok) + if err != nil { + return "", err + } + if tok == nil { + return "", nil + } + return *tok, nil +} + +// CreateUserWithWeChat inserts a registered user bound to WeChat. +func (r *AuthRepo) CreateUserWithWeChat(ctx context.Context, phone, hash, nickname, openid, unionID string) (uuid.UUID, error) { + var id uuid.UUID + err := r.Pool.QueryRow(ctx, ` + INSERT INTO users(phone, password_hash, nickname, wx_openid, wx_unionid) + VALUES ($1,$2,NULLIF($3,''),$4,NULLIF($5,'')) + RETURNING id`, + phone, hash, nickname, openid, unionID, + ).Scan(&id) + return id, err +} + // IsRegistered reports whether user has phone. func (r *AuthRepo) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, error) { var ok bool diff --git a/apps/api/internal/service/auth/service.go b/apps/api/internal/service/auth/service.go index fe229f4..19ca751 100644 --- a/apps/api/internal/service/auth/service.go +++ b/apps/api/internal/service/auth/service.go @@ -14,9 +14,11 @@ import ( "golang.org/x/crypto/bcrypt" "github.com/yuxingu/digital-psychology/apps/api/internal/avatar" + "github.com/yuxingu/digital-psychology/apps/api/internal/javabridge" nickgen "github.com/yuxingu/digital-psychology/apps/api/internal/nickname" "github.com/yuxingu/digital-psychology/apps/api/internal/repository" "github.com/yuxingu/digital-psychology/apps/api/internal/textsafe" + "github.com/yuxingu/digital-psychology/apps/api/internal/wechat" ) // Service handles register/login sessions. @@ -24,6 +26,9 @@ import ( type Service struct { Repo *repository.AuthRepo AvatarDir string + WeChat wechat.Client + WeChatApp string + Java *javabridge.Client } // Me is the public account payload. @@ -159,10 +164,106 @@ func (s *Service) UpdateAvatar(ctx context.Context, userID uuid.UUID, fh *multip return s.Me(ctx, userID) } +// WeChatLogin signs in with mini-program js_code + phone encryptedData. +func (s *Service) WeChatLogin(ctx context.Context, deviceUserID uuid.UUID, deviceKey, code, encryptedData, iv string) (*SessionResult, error) { + if s.WeChat == nil { + return nil, errors.New("微信登录未配置") + } + sess, err := s.WeChat.Code2Session(ctx, code) + if err != nil { + return nil, err + } + phone, err := wechat.DecryptPhone(sess.SessionKey, encryptedData, iv, s.WeChatApp) + if err != nil { + return nil, err + } + acc, err := s.Repo.GetByWxOpenid(ctx, sess.OpenID) + if err == nil { + if acc.Status != "active" { + return nil, ErrAccountRestricted + } + _ = s.Repo.BindWeChat(ctx, acc.ID, sess.OpenID, sess.UnionID, phone) + if deviceKey != "" { + _ = s.Repo.BindDevice(ctx, deviceKey, acc.ID) + } + return s.finishWeChat(ctx, acc.ID, phone, acc.Nickname, false, sess, deviceKey) + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + + byPhone, err := s.Repo.GetByPhone(ctx, phone) + if err == nil { + if byPhone.Status != "active" { + return nil, ErrAccountRestricted + } + _ = s.Repo.BindWeChat(ctx, byPhone.ID, sess.OpenID, sess.UnionID, phone) + if deviceKey != "" { + _ = s.Repo.BindDevice(ctx, deviceKey, byPhone.ID) + } + nick := byPhone.Nickname + if nick == "" { + nick = nickgen.Random() + _ = s.Repo.UpdateNickname(ctx, byPhone.ID, nick) + } + return s.finishWeChat(ctx, byPhone.ID, phone, nick, false, sess, deviceKey) + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + + hash, err := bcrypt.GenerateFromPassword([]byte("wx:"+sess.OpenID), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + nick := nickgen.Random() + uid := deviceUserID + cur, curErr := s.Repo.GetAccount(ctx, deviceUserID) + if curErr == nil && cur.Phone == "" && cur.WxOpenID == "" { + if cur.Status != "active" { + return nil, ErrAccountRestricted + } + if err := s.Repo.RegisterOnUser(ctx, deviceUserID, phone, string(hash), nick); err != nil { + return nil, errors.New("登录失败,请重试") + } + _ = s.Repo.BindWeChat(ctx, deviceUserID, sess.OpenID, sess.UnionID, phone) + } else { + uid, err = s.Repo.CreateUserWithWeChat(ctx, phone, string(hash), nick, sess.OpenID, sess.UnionID) + if err != nil { + return nil, errors.New("登录失败,请重试") + } + } + if deviceKey != "" { + _ = s.Repo.BindDevice(ctx, deviceKey, uid) + } + return s.finishWeChat(ctx, uid, phone, nick, true, sess, deviceKey) +} + +func (s *Service) finishWeChat(ctx context.Context, userID uuid.UUID, phone, nickname string, isNew bool, sess wechat.Session, _ string) (*SessionResult, error) { + res, err := s.issue(ctx, userID, phone, nickname, isNew) + if err != nil { + return nil, err + } + if s.Java != nil && s.Java.Enabled() { + if tok, jerr := s.Java.IssueMiniApp(ctx, sess.OpenID, sess.UnionID, phone); jerr == nil && tok != nil { + _ = s.Repo.SetSessionJavaToken(ctx, res.Token, tok.AccessToken) + if tok.MemberID > 0 { + _ = s.Repo.SetJavaPlatformUserID(ctx, userID, tok.MemberID) + } + } + } + return res, nil +} + // Logout revokes the current bearer session and unbinds the device from the account // so a refresh no longer resolves as logged-in via X-Device-Key (Spec R5). func (s *Service) Logout(ctx context.Context, token, deviceKey string) error { if token != "" { + if s.Java != nil && s.Java.Enabled() { + if jt, err := s.Repo.JavaTokenByGoToken(ctx, token); err == nil && jt != "" { + s.Java.Logout(ctx, jt) + } + } if err := s.Repo.RevokeSession(ctx, token); err != nil { return err } diff --git a/apps/api/internal/service/consult/service.go b/apps/api/internal/service/consult/service.go new file mode 100644 index 0000000..a1b50db --- /dev/null +++ b/apps/api/internal/service/consult/service.go @@ -0,0 +1,818 @@ +package consult + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/yuxingu/digital-psychology/apps/api/internal/wechatpay" +) + +// Service implements miniprogram consult APIs on PostgreSQL. +type Service struct { + Pool *pgxpool.Pool + Pay wechatpay.Config +} + +// Registered reports users.phone present. +func (s *Service) Registered(ctx context.Context, userID uuid.UUID) bool { + var ok bool + _ = s.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id=$1 AND phone IS NOT NULL AND deleted_at IS NULL)`, userID).Scan(&ok) + return ok +} + +func (s *Service) Banners(ctx context.Context) ([]map[string]any, error) { + rows, err := s.Pool.Query(ctx, `SELECT id, banner_name, banner_image, click_url, describe, order_index, created_at, jump_type FROM consult_banners ORDER BY order_index, id`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []map[string]any{} + for rows.Next() { + var id, order, jump int64 + var name, img, url, desc string + var created time.Time + if err := rows.Scan(&id, &name, &img, &url, &desc, &order, &created, &jump); err != nil { + return nil, err + } + out = append(out, map[string]any{ + "id": id, "bannerName": name, "bannerImage": img, "clickUrl": url, + "describe": desc, "orderIndex": order, "createTime": created.Format(time.RFC3339), "jumpType": jump, + }) + } + return out, rows.Err() +} + +func (s *Service) NewsPage(ctx context.Context, typ, showMain, pageNo, pageSize int) (map[string]any, error) { + if pageNo <= 0 { + pageNo = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + var total int + q := `SELECT COUNT(*) FROM consult_news WHERE ($1=0 OR type=$1) AND ($2<0 OR show_main=$2)` + if err := s.Pool.QueryRow(ctx, q, typ, showMain).Scan(&total); err != nil { + return nil, err + } + rows, err := s.Pool.Query(ctx, ` + SELECT id, type, title, show_image, content, show_main + FROM consult_news WHERE ($1=0 OR type=$1) AND ($2<0 OR show_main=$2) + ORDER BY id DESC OFFSET $3 LIMIT $4`, typ, showMain, (pageNo-1)*pageSize, pageSize) + if err != nil { + return nil, err + } + defer rows.Close() + list := []map[string]any{} + for rows.Next() { + var id, t, sm int64 + var title, img, content string + if err := rows.Scan(&id, &t, &title, &img, &content, &sm); err != nil { + return nil, err + } + list = append(list, map[string]any{"id": id, "type": t, "title": title, "showImage": img, "content": content, "showMain": sm}) + } + return map[string]any{"list": list, "total": total}, rows.Err() +} + +func (s *Service) NewsGet(ctx context.Context, id int64) (map[string]any, error) { + var t, sm int64 + var title, img, content string + err := s.Pool.QueryRow(ctx, `SELECT type, title, show_image, content, show_main FROM consult_news WHERE id=$1`, id). + Scan(&t, &title, &img, &content, &sm) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("资讯不存在") + } + if err != nil { + return nil, err + } + return map[string]any{"id": id, "type": t, "title": title, "showImage": img, "content": content, "showMain": sm}, nil +} + +func (s *Service) ProtocolByCode(ctx context.Context, code string) (map[string]any, error) { + var id int64 + var title, contextText string + err := s.Pool.QueryRow(ctx, `SELECT id, title, context FROM consult_protocols WHERE code=$1 AND status=1`, code). + Scan(&id, &title, &contextText) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("协议不存在") + } + if err != nil { + return nil, err + } + return map[string]any{"id": id, "code": code, "title": title, "context": contextText}, nil +} + +func (s *Service) TestList(ctx context.Context, showMain int) ([]map[string]any, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT id, test_name, sub_title, test_pic, total_num, show_main + FROM consult_tests WHERE status=1 AND ($1<0 OR show_main=$1) ORDER BY id`, showMain) + if err != nil { + return nil, err + } + defer rows.Close() + out := []map[string]any{} + for rows.Next() { + var id, total, sm int64 + var name, sub, pic string + if err := rows.Scan(&id, &name, &sub, &pic, &total, &sm); err != nil { + return nil, err + } + out = append(out, map[string]any{"id": id, "testName": name, "subTitle": sub, "testPic": pic, "totalNum": total, "showMain": sm}) + } + return out, rows.Err() +} + +func (s *Service) TestDetail(ctx context.Context, studyID int64) (map[string]any, error) { + var name, sub, pic, intro, notice string + var total int64 + err := s.Pool.QueryRow(ctx, ` + SELECT test_name, sub_title, test_pic, test_introduction, total_num, test_notice + FROM consult_tests WHERE id=$1 AND status=1`, studyID). + Scan(&name, &sub, &pic, &intro, &total, ¬ice) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("测评不存在") + } + if err != nil { + return nil, err + } + qrows, err := s.Pool.Query(ctx, ` + SELECT id, question_type, question_text, question_image, required, order_index + FROM consult_questions WHERE test_id=$1 ORDER BY order_index, id`, studyID) + if err != nil { + return nil, err + } + defer qrows.Close() + questions := []map[string]any{} + for qrows.Next() { + var qid, qt, req, ord int64 + var text, img string + if err := qrows.Scan(&qid, &qt, &text, &img, &req, &ord); err != nil { + return nil, err + } + orows, err := s.Pool.Query(ctx, `SELECT id, option_text, order_index FROM consult_options WHERE question_id=$1 ORDER BY order_index, id`, qid) + if err != nil { + return nil, err + } + opts := []map[string]any{} + for orows.Next() { + var oid, oord int64 + var ot string + if err := orows.Scan(&oid, &ot, &oord); err != nil { + orows.Close() + return nil, err + } + opts = append(opts, map[string]any{"id": oid, "optionText": ot, "orderIndex": oord}) + } + orows.Close() + questions = append(questions, map[string]any{ + "id": qid, "questionType": qt, "questionText": text, "questionImgae": img, + "required": req, "orderIndex": ord, "questionOptionVOList": opts, + }) + } + return map[string]any{ + "id": studyID, "testName": name, "subTitle": sub, "testPic": pic, + "testIntroduction": intro, "totalNum": total, "testNotice": notice, + "questionDetailVOList": questions, + }, nil +} + +type ChoiceIn struct { + ID int64 `json:"id"` + TotalTime int `json:"totalTime"` + UserChoiceOptionVOList []struct { + QuestionID int64 `json:"questionId"` + OptionID int64 `json:"optionId"` + } `json:"userChoiceOptionVOList"` +} + +func (s *Service) SaveChoice(ctx context.Context, userID uuid.UUID, in ChoiceIn) (int64, error) { + if in.ID == 0 { + return 0, errors.New("缺少测评") + } + score := 0 + for _, it := range in.UserChoiceOptionVOList { + var sc int + _ = s.Pool.QueryRow(ctx, `SELECT option_score FROM consult_options WHERE id=$1`, it.OptionID).Scan(&sc) + score += sc + } + var resultID int64 + err := s.Pool.QueryRow(ctx, ` + SELECT id FROM consult_test_results + WHERE test_id=$1 AND $2 BETWEEN min_score AND max_score + ORDER BY id LIMIT 1`, in.ID, score).Scan(&resultID) + if errors.Is(err, pgx.ErrNoRows) { + err = s.Pool.QueryRow(ctx, `SELECT id FROM consult_test_results WHERE test_id=$1 ORDER BY min_score LIMIT 1`, in.ID).Scan(&resultID) + } + if err != nil { + return 0, errors.New("暂无匹配结果") + } + raw, _ := json.Marshal(in.UserChoiceOptionVOList) + var choiceID int64 + err = s.Pool.QueryRow(ctx, ` + INSERT INTO consult_user_choices(user_id, test_id, result_id, total_time, choice_info) + VALUES ($1,$2,$3,$4,$5) RETURNING id`, userID, in.ID, resultID, in.TotalTime, raw).Scan(&choiceID) + if err != nil { + return 0, err + } + _, _ = s.Pool.Exec(ctx, `UPDATE consult_tests SET total_num=total_num+1, actual_num=actual_num+1 WHERE id=$1`, in.ID) + return resultID, nil +} + +func (s *Service) GetResult(ctx context.Context, resultID int64) (map[string]any, error) { + var testID int64 + var desc, analysis, plan, name, sub, pic string + err := s.Pool.QueryRow(ctx, ` + SELECT r.test_id, r.result_desc, r.result_analysis, r.treat_plan, t.test_name, t.sub_title, t.test_pic + FROM consult_test_results r JOIN consult_tests t ON t.id=r.test_id + WHERE r.id=$1`, resultID).Scan(&testID, &desc, &analysis, &plan, &name, &sub, &pic) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("结果不存在") + } + if err != nil { + return nil, err + } + return map[string]any{ + "id": resultID, "testId": testID, "testName": name, "subTitle": sub, "testPic": pic, + "resultDesc": desc, "resultAnalysis": analysis, "treatPlan": plan, + }, nil +} + +func (s *Service) MyTests(ctx context.Context, userID uuid.UUID, pageNo, pageSize int) (map[string]any, error) { + if pageNo <= 0 { + pageNo = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + var total int + _ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_user_choices WHERE user_id=$1`, userID).Scan(&total) + rows, err := s.Pool.Query(ctx, ` + SELECT c.id, t.test_name, t.test_pic, t.sub_title, c.result_id, to_char(c.created_at,'YYYY-MM-DD HH24:MI') + FROM consult_user_choices c JOIN consult_tests t ON t.id=c.test_id + WHERE c.user_id=$1 ORDER BY c.id DESC OFFSET $2 LIMIT $3`, userID, (pageNo-1)*pageSize, pageSize) + if err != nil { + return nil, err + } + defer rows.Close() + list := []map[string]any{} + for rows.Next() { + var id, rid int64 + var name, pic, sub, start string + if err := rows.Scan(&id, &name, &pic, &sub, &rid, &start); err != nil { + return nil, err + } + list = append(list, map[string]any{"id": id, "testName": name, "testPic": pic, "subTitle": sub, "testResultId": rid, "startTime": start}) + } + return map[string]any{"list": list, "total": total}, rows.Err() +} + +func (s *Service) Scopes(ctx context.Context) ([]map[string]any, error) { + rows, err := s.Pool.Query(ctx, `SELECT code, name FROM consult_business_scopes ORDER BY code`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []map[string]any{} + for rows.Next() { + var code, name string + if err := rows.Scan(&code, &name); err != nil { + return nil, err + } + out = append(out, map[string]any{"code": code, "name": name}) + } + return out, rows.Err() +} + +func (s *Service) DoctorPage(ctx context.Context, scope string, isTop, pageNo, pageSize int) (map[string]any, error) { + if pageNo <= 0 { + pageNo = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + scope = strings.TrimSpace(scope) + var total int + _ = s.Pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM consult_doctors + WHERE status=1 AND ($1='' OR business_scope LIKE '%'||$1||'%') AND ($2<0 OR is_top=$2)`, scope, isTop).Scan(&total) + rows, err := s.Pool.Query(ctx, ` + SELECT id, name, avatar, business_scope, tags, consultation_method, introduction, price + FROM consult_doctors + WHERE status=1 AND ($1='' OR business_scope LIKE '%'||$1||'%') AND ($2<0 OR is_top=$2) + ORDER BY is_top DESC, id OFFSET $3 LIMIT $4`, scope, isTop, (pageNo-1)*pageSize, pageSize) + if err != nil { + return nil, err + } + defer rows.Close() + list := []map[string]any{} + for rows.Next() { + var id, price int64 + var name, avatar, bs, tags, method, intro string + if err := rows.Scan(&id, &name, &avatar, &bs, &tags, &method, &intro, &price); err != nil { + return nil, err + } + list = append(list, map[string]any{ + "id": id, "name": name, "avatar": avatar, "businessScope": bs, "tags": tags, + "consultationMethod": method, "introduction": intro, "price": price, "availableDate": "", + }) + } + return map[string]any{"list": list, "total": total}, rows.Err() +} + +func (s *Service) DoctorGet(ctx context.Context, id int64, userID uuid.UUID) (map[string]any, error) { + var name, avatar, bs, cover, method, edu, intro, resume, notice, tags, exp, addr, addrD string + var price, status, showN, top int64 + var workStart *time.Time + var created time.Time + err := s.Pool.QueryRow(ctx, ` + SELECT name, avatar, business_scope, cover_url, consultation_method, education, introduction, resume, notice, tags, + work_experience, work_start_time, price, status, address, address_detail, show_service_num, is_top, created_at + FROM consult_doctors WHERE id=$1`, id).Scan( + &name, &avatar, &bs, &cover, &method, &edu, &intro, &resume, ¬ice, &tags, + &exp, &workStart, &price, &status, &addr, &addrD, &showN, &top, &created) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("咨询师不存在") + } + if err != nil { + return nil, err + } + focus := false + if userID != uuid.Nil { + _ = s.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM consult_focus WHERE user_id=$1 AND doctor_id=$2 AND status=1)`, userID, id).Scan(&focus) + } + ws := "" + if workStart != nil { + ws = workStart.Format("2006-01-02") + } + if strings.TrimSpace(cover) == "" { + cover = avatar + } + return map[string]any{ + "id": id, "userId": 0, "name": name, "avatar": avatar, "businessScope": bs, "coverUrl": cover, + "consultationMethod": method, "education": edu, "introduction": intro, "resume": resume, + "notice": notice, "tags": tags, "workExperience": exp, "workStartTime": ws, "price": price, + "createTime": created.Format(time.RFC3339), "focus": focus, "address": addr, "addressDetail": addrD, + }, nil +} + +func (s *Service) SetFocus(ctx context.Context, userID uuid.UUID, doctorID int64, on bool) error { + st := 0 + if on { + st = 1 + } + _, err := s.Pool.Exec(ctx, ` + INSERT INTO consult_focus(user_id, doctor_id, status, read_status) + VALUES ($1,$2,$3,0) + ON CONFLICT (user_id, doctor_id) DO UPDATE SET status=$3, read_status=0`, userID, doctorID, st) + return err +} + +func (s *Service) ShowInfo(ctx context.Context, doctorID int64, userID uuid.UUID) (map[string]any, error) { + d, err := s.DoctorGet(ctx, doctorID, userID) + if err != nil { + return nil, err + } + out := map[string]any{ + "id": d["id"], "name": d["name"], "avatar": d["avatar"], + "consultationMethod": d["consultationMethod"], "price": d["price"], "address": d["address"], + } + if userID != uuid.Nil { + var name, phone string + var sex int + var bday *time.Time + var em string + err := s.Pool.QueryRow(ctx, ` + SELECT appointment_name, birthday, phone, sex, emergency_contact_info + FROM consult_orders WHERE user_id=$1 AND user_deleted=0 ORDER BY id DESC LIMIT 1`, userID). + Scan(&name, &bday, &phone, &sex, &em) + if err == nil { + bs := "" + if bday != nil { + bs = bday.Format("2006-01-02") + } + emPhone, emType := "", 1 + var obj map[string]any + if json.Unmarshal([]byte(em), &obj) == nil { + if v, ok := obj["phone"].(string); ok { + emPhone = v + } + switch v := obj["type"].(type) { + case float64: + emType = int(v) + } + } + out["appointmentInfoAppRespVO"] = map[string]any{ + "appointmentName": name, "birthday": bs, "phone": phone, "sex": sex, + "emergencyContactPhone": emPhone, "emergencyContactType": emType, + } + } + } + return out, nil +} + +func (s *Service) RemainList(ctx context.Context, doctorID int64) ([]map[string]any, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT d.schedule_date, d.id, + (SELECT COUNT(*) FROM consult_slots s WHERE s.schedule_id=d.id AND s.status=0) + FROM consult_schedule_days d + WHERE d.doctor_id=$1 AND d.schedule_date >= CURRENT_DATE AND d.schedule_date < CURRENT_DATE+30 + ORDER BY d.schedule_date`, doctorID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []map[string]any{} + for rows.Next() { + var day time.Time + var sid, remain int64 + if err := rows.Scan(&day, &sid, &remain); err != nil { + return nil, err + } + out = append(out, map[string]any{"scheduleDate": day.Format("2006-01-02"), "remainNum": remain, "scheduleDateId": sid}) + } + return out, rows.Err() +} + +func (s *Service) DateDetail(ctx context.Context, doctorID int64) ([]map[string]any, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT d.id, d.schedule_date FROM consult_schedule_days d + WHERE d.doctor_id=$1 AND d.schedule_date >= CURRENT_DATE AND d.schedule_date < CURRENT_DATE+13 + ORDER BY d.schedule_date`, doctorID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []map[string]any{} + for rows.Next() { + var sid int64 + var day time.Time + if err := rows.Scan(&sid, &day); err != nil { + return nil, err + } + srows, err := s.Pool.Query(ctx, ` + SELECT id, start_time, end_time, consultation_method FROM consult_slots + WHERE schedule_id=$1 AND status=0 ORDER BY start_time`, sid) + if err != nil { + return nil, err + } + slots := []map[string]any{} + for srows.Next() { + var id int64 + var st, et time.Time + var method string + if err := srows.Scan(&id, &st, &et, &method); err != nil { + srows.Close() + return nil, err + } + slots = append(slots, map[string]any{ + "slotId": id, "startTime": st.Format("15:04:05"), "endTime": et.Format("15:04:05"), + "consultationMethod": method, + }) + } + srows.Close() + out = append(out, map[string]any{ + "scheduleId": sid, "scheduleDate": day.Format("2006-01-02"), + "weekStr": weekdayCN(day), "slotVOList": slots, + }) + } + return out, rows.Err() +} + +type OrderIn struct { + SlotID int64 `json:"slotId"` + AppointmentName string `json:"appointmentName"` + Birthday string `json:"birthday"` + Phone string `json:"phone"` + Sex int `json:"sex"` + EmergencyContactInfo string `json:"emergencyContactInfo"` + TotalAmount int `json:"totalAmount"` + ConsultationMethod string `json:"consultationMethod"` +} + +func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in OrderIn) (wechatpay.JSAPIParams, error) { + var doctorID int64 + var day time.Time + var st, et time.Time + var status int + var price int + err := s.Pool.QueryRow(ctx, ` + SELECT s.doctor_id, d.schedule_date, s.start_time, s.end_time, s.status, doc.price + FROM consult_slots s + JOIN consult_schedule_days d ON d.id=s.schedule_id + JOIN consult_doctors doc ON doc.id=s.doctor_id + WHERE s.id=$1`, in.SlotID).Scan(&doctorID, &day, &st, &et, &status, &price) + if errors.Is(err, pgx.ErrNoRows) { + return wechatpay.JSAPIParams{}, errors.New("时段不存在") + } + if err != nil { + return wechatpay.JSAPIParams{}, err + } + if status != 0 { + return wechatpay.JSAPIParams{}, errors.New("该时段已被预约") + } + if in.TotalAmount > 0 && in.TotalAmount != price { + return wechatpay.JSAPIParams{}, errors.New("价格已变化,请刷新") + } + tx, err := s.Pool.Begin(ctx) + if err != nil { + return wechatpay.JSAPIParams{}, err + } + defer tx.Rollback(ctx) + tag, err := tx.Exec(ctx, `UPDATE consult_slots SET status=1 WHERE id=$1 AND status=0`, in.SlotID) + if err != nil || tag.RowsAffected() == 0 { + return wechatpay.JSAPIParams{}, errors.New("该时段已被预约") + } + sn := fmt.Sprintf("YG%s%06d", time.Now().Format("20060102150405"), in.SlotID%1000000) + method := in.ConsultationMethod + if method == "" { + method = "online" + } + var bday *time.Time + if in.Birthday != "" { + if t, e := time.Parse("2006-01-02", in.Birthday); e == nil { + bday = &t + } + } + valid := time.Now().Add(15 * time.Minute) + var oid int64 + err = tx.QueryRow(ctx, ` + INSERT INTO consult_orders(order_sn, slot_id, doctor_id, user_id, appointment_date, start_time, end_time, status, + appointment_name, birthday, phone, sex, emergency_contact_info, total_amount, valid_time, consultation_method) + VALUES ($1,$2,$3,$4,$5,$6,$7,0,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING id`, + sn, in.SlotID, doctorID, userID, day, st, et, in.AppointmentName, bday, in.Phone, in.Sex, + in.EmergencyContactInfo, price, valid, method).Scan(&oid) + if err != nil { + return wechatpay.JSAPIParams{}, err + } + openid := "" + _ = tx.QueryRow(ctx, `SELECT COALESCE(wx_openid,'') FROM users WHERE id=$1`, userID).Scan(&openid) + pay, err := wechatpay.UnifiedOrder(s.Pay, openid, sn, price) + if err != nil { + return wechatpay.JSAPIParams{}, err + } + raw, _ := json.Marshal(pay) + _, _ = tx.Exec(ctx, `UPDATE consult_orders SET pay_param=$2 WHERE id=$1`, oid, raw) + if err := tx.Commit(ctx); err != nil { + return wechatpay.JSAPIParams{}, err + } + return pay, nil +} + +func (s *Service) PayParam(ctx context.Context, userID uuid.UUID, orderSn string) (wechatpay.JSAPIParams, error) { + var raw []byte + var owner uuid.UUID + err := s.Pool.QueryRow(ctx, `SELECT user_id, pay_param FROM consult_orders WHERE order_sn=$1 AND user_deleted=0`, orderSn). + Scan(&owner, &raw) + if err != nil { + return wechatpay.JSAPIParams{}, errors.New("订单不存在") + } + if owner != userID { + return wechatpay.JSAPIParams{}, errors.New("无权查看") + } + var p wechatpay.JSAPIParams + if len(raw) > 0 { + _ = json.Unmarshal(raw, &p) + } + if p.PaySign == "" { + p = wechatpay.MockJSAPI() + } + return p, nil +} + +func (s *Service) OrderList(ctx context.Context, userID uuid.UUID, pageNo, pageSize int) (map[string]any, error) { + if pageNo <= 0 { + pageNo = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + var total int + _ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_orders WHERE user_id=$1 AND user_deleted=0`, userID).Scan(&total) + rows, err := s.Pool.Query(ctx, ` + SELECT o.id, o.doctor_id, o.order_sn, d.name, d.avatar, o.consultation_method, o.status, o.total_amount, + to_char(o.start_time,'HH24:MI:SS'), to_char(o.end_time,'HH24:MI:SS'), o.valid_time, o.created_at, o.cancel_time, o.cancel_flag + FROM consult_orders o JOIN consult_doctors d ON d.id=o.doctor_id + WHERE o.user_id=$1 AND o.user_deleted=0 + ORDER BY o.id DESC OFFSET $2 LIMIT $3`, userID, (pageNo-1)*pageSize, pageSize) + if err != nil { + return nil, err + } + defer rows.Close() + list := []map[string]any{} + for rows.Next() { + var id, did, status, amount, cancelFlag int64 + var sn, name, avatar, method, st, et string + var valid, created *time.Time + var cancel *time.Time + if err := rows.Scan(&id, &did, &sn, &name, &avatar, &method, &status, &amount, &st, &et, &valid, &created, &cancel, &cancelFlag); err != nil { + return nil, err + } + item := map[string]any{ + "id": id, "doctorId": did, "orderSn": sn, "name": name, "avatar": avatar, + "consultationMethod": method, "status": status, "totalAmount": amount, + "startTime": st, "endTime": et, "cancelFlag": cancelFlag, + } + if valid != nil { + item["validTime"] = valid.Format(time.RFC3339) + } + if created != nil { + item["createTime"] = created.Format(time.RFC3339) + } + if cancel != nil { + item["cancelTime"] = cancel.Format(time.RFC3339) + } + list = append(list, item) + } + return map[string]any{"list": list, "total": total}, rows.Err() +} + +func (s *Service) OrderDetail(ctx context.Context, userID uuid.UUID, orderID int64) (map[string]any, error) { + var did int64 + var sn, aname, phone, method, em, addrD string + var status, sex, amount, cancelFlag int64 + var bday *time.Time + var st, et time.Time + var day time.Time + var name, avatar string + err := s.Pool.QueryRow(ctx, ` + SELECT o.doctor_id, o.order_sn, o.appointment_name, o.birthday, o.phone, o.sex, o.emergency_contact_info, + o.status, o.total_amount, o.consultation_method, o.cancel_flag, o.appointment_date, o.start_time, o.end_time, + d.name, d.avatar, d.address_detail + FROM consult_orders o JOIN consult_doctors d ON d.id=o.doctor_id + WHERE o.id=$1 AND o.user_id=$2 AND o.user_deleted=0`, orderID, userID). + Scan(&did, &sn, &aname, &bday, &phone, &sex, &em, &status, &amount, &method, &cancelFlag, &day, &st, &et, &name, &avatar, &addrD) + if err != nil { + return nil, errors.New("订单不存在") + } + bs := "" + if bday != nil { + bs = bday.Format("2006-01-02") + } + return map[string]any{ + "id": orderID, "doctorId": did, "orderSn": sn, "name": name, "avatar": avatar, + "appointmentName": aname, "birthday": bs, "phone": phone, "sex": sex, + "emergencyContactInfo": em, "status": status, "totalAmount": amount, + "consultationMethod": method, "cancelFlag": cancelFlag, "addressDetail": addrD, + "appointmentDate": day.Format("2006-01-02"), + "startTime": st.Format("15:04:05"), "endTime": et.Format("15:04:05"), + }, nil +} + +func (s *Service) CancelOrder(ctx context.Context, userID uuid.UUID, orderID int64) error { + var slotID int64 + var status int + err := s.Pool.QueryRow(ctx, `SELECT slot_id, status FROM consult_orders WHERE id=$1 AND user_id=$2 AND user_deleted=0`, orderID, userID). + Scan(&slotID, &status) + if err != nil { + return errors.New("订单不存在") + } + if status != 0 { + return errors.New("当前状态不可取消") + } + _, _ = s.Pool.Exec(ctx, `UPDATE consult_slots SET status=0 WHERE id=$1`, slotID) + _, err = s.Pool.Exec(ctx, `UPDATE consult_orders SET status=9, cancel_time=now(), cancel_flag=2 WHERE id=$1`, orderID) + return err +} + +func (s *Service) DeleteOrder(ctx context.Context, userID uuid.UUID, orderID int64) error { + tag, err := s.Pool.Exec(ctx, `UPDATE consult_orders SET user_deleted=1 WHERE id=$1 AND user_id=$2`, orderID, userID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return errors.New("订单不存在") + } + return nil +} + +func (s *Service) MarkPaid(ctx context.Context, orderSn string) error { + _, err := s.Pool.Exec(ctx, `UPDATE consult_orders SET status=1, pay_time=now() WHERE order_sn=$1 AND status=0`, orderSn) + return err +} + +func (s *Service) DemoPay(ctx context.Context, userID uuid.UUID) (wechatpay.JSAPIParams, error) { + openid := "" + _ = s.Pool.QueryRow(ctx, `SELECT COALESCE(wx_openid,'') FROM users WHERE id=$1`, userID).Scan(&openid) + return wechatpay.UnifiedOrder(s.Pay, openid, fmt.Sprintf("DEMO%d", time.Now().Unix()), 1) +} + +func (s *Service) SelfInfo(ctx context.Context, userID uuid.UUID) (map[string]any, error) { + var nick, avatar, phone, status string + var created time.Time + err := s.Pool.QueryRow(ctx, `SELECT COALESCE(nickname,''), COALESCE(avatar_url,''), COALESCE(phone,''), status, created_at FROM users WHERE id=$1`, userID). + Scan(&nick, &avatar, &phone, &status, &created) + if err != nil { + return nil, errors.New("请先登录") + } + var stay, idCard, realName string + _ = s.Pool.QueryRow(ctx, `SELECT COALESCE(stay_period,''), COALESCE(id_card,''), COALESCE(real_name,'') FROM consult_user_ext WHERE user_id=$1`, userID). + Scan(&stay, &idCard, &realName) + st := 1 + if status != "active" { + st = 0 + } + return map[string]any{ + "id": userID.String(), "status": st, "idCard": idCard, "realName": realName, + "createTime": created.Format(time.RFC3339), "nickName": nick, "avatarUrl": avatar, "stayPeriod": stay, "phone": phone, + }, nil +} + +func (s *Service) UpdateSelf(ctx context.Context, userID uuid.UUID, nick, avatar, stay string) error { + if nick != "" { + _, _ = s.Pool.Exec(ctx, `UPDATE users SET nickname=$2, updated_at=now() WHERE id=$1`, userID, nick) + } + if avatar != "" { + _, _ = s.Pool.Exec(ctx, `UPDATE users SET avatar_url=$2, updated_at=now() WHERE id=$1`, userID, avatar) + } + _, err := s.Pool.Exec(ctx, ` + INSERT INTO consult_user_ext(user_id, stay_period) VALUES ($1,$2) + ON CONFLICT (user_id) DO UPDATE SET stay_period=COALESCE(NULLIF($2,''), consult_user_ext.stay_period), updated_at=now()`, + userID, stay) + return err +} + +func (s *Service) NotRead(ctx context.Context, userID uuid.UUID) (map[string]any, error) { + var focus, order int + _ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_focus WHERE user_id=$1 AND status=1 AND read_status=0`, userID).Scan(&focus) + _ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_orders WHERE user_id=$1 AND user_deleted=0 AND user_read=0`, userID).Scan(&order) + return map[string]any{"focusNum": focus, "orderNum": order}, nil +} + +func (s *Service) FocusToRead(ctx context.Context, userID uuid.UUID) error { + _, err := s.Pool.Exec(ctx, `UPDATE consult_focus SET read_status=1 WHERE user_id=$1`, userID) + return err +} + +func (s *Service) OrderToRead(ctx context.Context, userID uuid.UUID) error { + _, err := s.Pool.Exec(ctx, `UPDATE consult_orders SET user_read=1 WHERE user_id=$1`, userID) + return err +} + +func (s *Service) FocusAll(ctx context.Context, userID uuid.UUID) ([]map[string]any, error) { + rows, err := s.Pool.Query(ctx, ` + SELECT d.id, d.name, d.avatar, d.show_service_num, d.introduction, d.price, d.work_start_time + FROM consult_focus f JOIN consult_doctors d ON d.id=f.doctor_id + WHERE f.user_id=$1 AND f.status=1 ORDER BY f.id DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []map[string]any{} + for rows.Next() { + var id, show, price int64 + var name, avatar, intro string + var ws *time.Time + if err := rows.Scan(&id, &name, &avatar, &show, &intro, &price, &ws); err != nil { + return nil, err + } + workNum := 0 + if ws != nil { + workNum = time.Now().Year() - ws.Year() + if workNum < 0 { + workNum = 0 + } + } + out = append(out, map[string]any{ + "id": id, "name": name, "avatar": avatar, "workNum": workNum, + "showServiceNum": show, "introduction": intro, "price": price, + "workStartTime": timeOrEmpty(ws), + }) + } + return out, rows.Err() +} + +func (s *Service) Feedback(ctx context.Context, userID uuid.UUID, text string) error { + if strings.TrimSpace(text) == "" { + return errors.New("请填写内容") + } + phone := "" + _ = s.Pool.QueryRow(ctx, `SELECT COALESCE(phone,'') FROM users WHERE id=$1`, userID).Scan(&phone) + _, err := s.Pool.Exec(ctx, `INSERT INTO consult_feedback(user_id, content_text, contact) VALUES ($1,$2,$3)`, userID, text, phone) + return err +} + +func (s *Service) FeedbackFlag(ctx context.Context, userID uuid.UUID) (bool, error) { + var n int + _ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_feedback WHERE user_id=$1 AND created_at > now()-interval '5 minutes'`, userID).Scan(&n) + return n == 0, nil +} + +func weekdayCN(t time.Time) string { + names := []string{"星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"} + return names[int(t.Weekday())] +} + +func timeOrEmpty(t *time.Time) string { + if t == nil { + return "" + } + return t.Format("2006-01-02") +} diff --git a/apps/api/internal/wechat/client.go b/apps/api/internal/wechat/client.go new file mode 100644 index 0000000..a775210 --- /dev/null +++ b/apps/api/internal/wechat/client.go @@ -0,0 +1,84 @@ +package wechat + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// Session is the jscode2session result. +type Session struct { + OpenID string + UnionID string + SessionKey string +} + +// Client exchanges a mini-program js_code for a session. +type Client interface { + Code2Session(ctx context.Context, code string) (Session, error) +} + +// APIClient calls api.weixin.qq.com. +type APIClient struct { + AppID string + Secret string + HTTP *http.Client +} + +type wxSessionResp struct { + OpenID string `json:"openid"` + SessionKey string `json:"session_key"` + UnionID string `json:"unionid"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +// Enabled reports whether WeChat credentials are configured. +func (c *APIClient) Enabled() bool { + return c != nil && strings.TrimSpace(c.AppID) != "" && strings.TrimSpace(c.Secret) != "" +} + +// Code2Session implements Client. +func (c *APIClient) Code2Session(ctx context.Context, code string) (Session, error) { + if !c.Enabled() { + return Session{}, errors.New("微信登录未配置") + } + code = strings.TrimSpace(code) + if code == "" { + return Session{}, errors.New("缺少微信登录码") + } + q := url.Values{} + q.Set("appid", c.AppID) + q.Set("secret", c.Secret) + q.Set("js_code", code) + q.Set("grant_type", "authorization_code") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.weixin.qq.com/sns/jscode2session?"+q.Encode(), nil) + if err != nil { + return Session{}, err + } + cli := c.HTTP + if cli == nil { + cli = &http.Client{Timeout: 10 * time.Second} + } + resp, err := cli.Do(req) + if err != nil { + return Session{}, errors.New("微信服务暂不可用") + } + defer resp.Body.Close() + var body wxSessionResp + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return Session{}, errors.New("微信服务暂不可用") + } + if body.ErrCode != 0 { + return Session{}, fmt.Errorf("微信登录失败(%d)", body.ErrCode) + } + if body.OpenID == "" || body.SessionKey == "" { + return Session{}, errors.New("微信登录失败") + } + return Session{OpenID: body.OpenID, UnionID: body.UnionID, SessionKey: body.SessionKey}, nil +} diff --git a/apps/api/internal/wechat/phone.go b/apps/api/internal/wechat/phone.go new file mode 100644 index 0000000..ede3cc7 --- /dev/null +++ b/apps/api/internal/wechat/phone.go @@ -0,0 +1,75 @@ +package wechat + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "errors" + "strings" +) + +type phonePayload struct { + PhoneNumber string `json:"phoneNumber"` + PurePhoneNumber string `json:"purePhoneNumber"` + Watermark struct { + AppID string `json:"appid"` + } `json:"watermark"` +} + +// DecryptPhone decrypts WeChat getPhoneNumber encryptedData (AES-128-CBC). +func DecryptPhone(sessionKey, encryptedData, iv, expectAppID string) (string, error) { + key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(sessionKey)) + if err != nil || len(key) != 16 { + return "", errors.New("无效的 session_key") + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encryptedData)) + if err != nil || len(raw) == 0 || len(raw)%aes.BlockSize != 0 { + return "", errors.New("无效的加密数据") + } + ivb, err := base64.StdEncoding.DecodeString(strings.TrimSpace(iv)) + if err != nil || len(ivb) != aes.BlockSize { + return "", errors.New("无效的 iv") + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + plain := make([]byte, len(raw)) + cipher.NewCBCDecrypter(block, ivb).CryptBlocks(plain, raw) + plain, err = pkcs7Unpad(plain) + if err != nil { + return "", errors.New("解密失败") + } + var p phonePayload + if err := json.Unmarshal(plain, &p); err != nil { + return "", errors.New("解密失败") + } + if expectAppID != "" && p.Watermark.AppID != "" && p.Watermark.AppID != expectAppID { + return "", errors.New("微信数据校验失败") + } + phone := strings.TrimSpace(p.PhoneNumber) + if phone == "" { + phone = strings.TrimSpace(p.PurePhoneNumber) + } + if phone == "" { + return "", errors.New("未获取到手机号") + } + return phone, nil +} + +func pkcs7Unpad(b []byte) ([]byte, error) { + if len(b) == 0 { + return nil, errors.New("empty") + } + n := int(b[len(b)-1]) + if n == 0 || n > len(b) { + return nil, errors.New("pad") + } + for i := 0; i < n; i++ { + if b[len(b)-1-i] != byte(n) { + return nil, errors.New("pad") + } + } + return b[:len(b)-n], nil +} diff --git a/apps/api/internal/wechat/phone_test.go b/apps/api/internal/wechat/phone_test.go new file mode 100644 index 0000000..b9cdf06 --- /dev/null +++ b/apps/api/internal/wechat/phone_test.go @@ -0,0 +1,47 @@ +package wechat + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "testing" +) + +func TestDecryptPhone(t *testing.T) { + key := make([]byte, 16) + iv := make([]byte, 16) + _, _ = rand.Read(key) + _, _ = rand.Read(iv) + plain := []byte(`{"phoneNumber":"13800138000","purePhoneNumber":"13800138000","watermark":{"appid":"wxapp"}}`) + padded := pkcs7Pad(plain, aes.BlockSize) + block, err := aes.NewCipher(key) + if err != nil { + t.Fatal(err) + } + enc := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(enc, padded) + + phone, err := DecryptPhone( + base64.StdEncoding.EncodeToString(key), + base64.StdEncoding.EncodeToString(enc), + base64.StdEncoding.EncodeToString(iv), + "wxapp", + ) + if err != nil { + t.Fatal(err) + } + if phone != "13800138000" { + t.Fatalf("got %s", phone) + } +} + +func pkcs7Pad(b []byte, block int) []byte { + n := block - (len(b) % block) + out := make([]byte, len(b)+n) + copy(out, b) + for i := 0; i < n; i++ { + out[len(b)+i] = byte(n) + } + return out +} diff --git a/apps/api/internal/wechatpay/sign.go b/apps/api/internal/wechatpay/sign.go new file mode 100644 index 0000000..b025b3d --- /dev/null +++ b/apps/api/internal/wechatpay/sign.go @@ -0,0 +1,158 @@ +package wechatpay + +import ( + "crypto/md5" + "crypto/rand" + "encoding/hex" + "encoding/xml" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +// Config is WeChat JSAPI merchant config. +type Config struct { + AppID string + MchID string + APIKey string + NotifyURL string +} + +// Enabled reports merchant credentials present. +func (c Config) Enabled() bool { + return strings.TrimSpace(c.AppID) != "" && strings.TrimSpace(c.MchID) != "" && strings.TrimSpace(c.APIKey) != "" +} + +// JSAPIParams is returned to uni.requestPayment. +type JSAPIParams struct { + TimeStamp string `json:"timeStamp"` + NonceStr string `json:"nonceStr"` + PackageStr string `json:"packageStr"` + SignType string `json:"signType"` + PaySign string `json:"paySign"` +} + +// SignMD5 builds WeChat v2 sign. +func SignMD5(params map[string]string, apiKey string) string { + keys := make([]string, 0, len(params)) + for k, v := range params { + if k == "sign" || v == "" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for i, k := range keys { + if i > 0 { + b.WriteByte('&') + } + b.WriteString(k) + b.WriteByte('=') + b.WriteString(params[k]) + } + b.WriteString("&key=") + b.WriteString(apiKey) + sum := md5.Sum([]byte(b.String())) + return strings.ToUpper(hex.EncodeToString(sum[:])) +} + +// BuildJSAPI signs prepay_id for the miniprogram. +func BuildJSAPI(appID, prepayID, apiKey string) JSAPIParams { + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonce := nonceStr() + pkg := "prepay_id=" + prepayID + m := map[string]string{ + "appId": appID, "timeStamp": ts, "nonceStr": nonce, + "package": pkg, "signType": "MD5", + } + return JSAPIParams{ + TimeStamp: ts, NonceStr: nonce, PackageStr: pkg, SignType: "MD5", + PaySign: SignMD5(m, apiKey), + } +} + +// MockJSAPI is used when merchant keys are absent (dev). +func MockJSAPI() JSAPIParams { + return JSAPIParams{ + TimeStamp: strconv.FormatInt(time.Now().Unix(), 10), NonceStr: nonceStr(), + PackageStr: "prepay_id=mock", SignType: "MD5", PaySign: "MOCK", + } +} + +type unifiedXML struct { + XMLName xml.Name `xml:"xml"` + AppID string `xml:"appid"` + MchID string `xml:"mch_id"` + Nonce string `xml:"nonce_str"` + Body string `xml:"body"` + OutNo string `xml:"out_trade_no"` + Fee string `xml:"total_fee"` + IP string `xml:"spbill_create_ip"` + Notify string `xml:"notify_url"` + Trade string `xml:"trade_type"` + OpenID string `xml:"openid"` + Sign string `xml:"sign"` +} + +type unifiedResp struct { + ReturnCode string `xml:"return_code"` + ResultCode string `xml:"result_code"` + PrepayID string `xml:"prepay_id"` + ErrCodeDes string `xml:"err_code_des"` + ReturnMsg string `xml:"return_msg"` +} + +// UnifiedOrder calls WeChat v2 JSAPI unifiedorder. +func UnifiedOrder(cfg Config, openid, orderNo string, amountFen int) (JSAPIParams, error) { + if !cfg.Enabled() { + return MockJSAPI(), nil + } + nonce := nonceStr() + params := map[string]string{ + "appid": cfg.AppID, "mch_id": cfg.MchID, "nonce_str": nonce, + "body": "课程预约", "out_trade_no": orderNo, "total_fee": strconv.Itoa(amountFen), + "spbill_create_ip": "127.0.0.1", "notify_url": cfg.NotifyURL, + "trade_type": "JSAPI", "openid": openid, + } + params["sign"] = SignMD5(params, cfg.APIKey) + raw, err := xml.Marshal(unifiedXML{ + AppID: cfg.AppID, MchID: cfg.MchID, Nonce: nonce, Body: "课程预约", + OutNo: orderNo, Fee: params["total_fee"], IP: "127.0.0.1", + Notify: cfg.NotifyURL, Trade: "JSAPI", OpenID: openid, Sign: params["sign"], + }) + if err != nil { + return JSAPIParams{}, err + } + resp, err := http.Post("https://api.mch.weixin.qq.com/pay/unifiedorder", "application/xml", strings.NewReader(string(raw))) + if err != nil { + return JSAPIParams{}, err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + var out unifiedResp + if err := xml.Unmarshal(b, &out); err != nil { + return JSAPIParams{}, err + } + if out.ReturnCode != "SUCCESS" || out.ResultCode != "SUCCESS" || out.PrepayID == "" { + msg := out.ErrCodeDes + if msg == "" { + msg = out.ReturnMsg + } + if msg == "" { + msg = "创建订单失败" + } + return JSAPIParams{}, fmt.Errorf("%s", msg) + } + return BuildJSAPI(cfg.AppID, out.PrepayID, cfg.APIKey), nil +} + +func nonceStr() string { + var b [8]byte + _, _ = rand.Read(b[:]) + return strings.ToUpper(hex.EncodeToString(b[:])) +} diff --git a/apps/api/internal/wechatpay/sign_test.go b/apps/api/internal/wechatpay/sign_test.go new file mode 100644 index 0000000..aff0883 --- /dev/null +++ b/apps/api/internal/wechatpay/sign_test.go @@ -0,0 +1,17 @@ +package wechatpay + +import ( + "crypto/md5" + "encoding/hex" + "strings" + "testing" +) + +func TestSignMD5(t *testing.T) { + got := SignMD5(map[string]string{"appid": "wx123", "mch_id": "foo", "sign": "x"}, "k") + sum := md5.Sum([]byte("appid=wx123&mch_id=foo&key=k")) + want := strings.ToUpper(hex.EncodeToString(sum[:])) + if got != want { + t.Fatalf("got %s want %s", got, want) + } +} diff --git a/apps/api/migrations/000058_wechat_auth_gateway.down.sql b/apps/api/migrations/000058_wechat_auth_gateway.down.sql new file mode 100644 index 0000000..c811fa8 --- /dev/null +++ b/apps/api/migrations/000058_wechat_auth_gateway.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE user_sessions DROP COLUMN IF EXISTS java_access_token; +DROP INDEX IF EXISTS idx_users_wx_openid_unique; +ALTER TABLE users DROP COLUMN IF EXISTS java_platform_user_id; +ALTER TABLE users DROP COLUMN IF EXISTS wx_unionid; +ALTER TABLE users DROP COLUMN IF EXISTS wx_openid; diff --git a/apps/api/migrations/000058_wechat_auth_gateway.up.sql b/apps/api/migrations/000058_wechat_auth_gateway.up.sql new file mode 100644 index 0000000..49586cb --- /dev/null +++ b/apps/api/migrations/000058_wechat_auth_gateway.up.sql @@ -0,0 +1,10 @@ +-- ECR-049: WeChat identity + Java consult token on session + +ALTER TABLE users ADD COLUMN IF NOT EXISTS wx_openid varchar(64) NULL; +ALTER TABLE users ADD COLUMN IF NOT EXISTS wx_unionid varchar(64) NULL; +ALTER TABLE users ADD COLUMN IF NOT EXISTS java_platform_user_id bigint NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_wx_openid_unique + ON users(wx_openid) WHERE wx_openid IS NOT NULL AND deleted_at IS NULL; + +ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS java_access_token text NULL; diff --git a/apps/api/migrations/000059_consult_native.down.sql b/apps/api/migrations/000059_consult_native.down.sql new file mode 100644 index 0000000..1357e5d --- /dev/null +++ b/apps/api/migrations/000059_consult_native.down.sql @@ -0,0 +1,16 @@ +DROP TABLE IF EXISTS consult_orders; +DROP TABLE IF EXISTS consult_user_choices; +DROP TABLE IF EXISTS consult_test_results; +DROP TABLE IF EXISTS consult_options; +DROP TABLE IF EXISTS consult_questions; +DROP TABLE IF EXISTS consult_tests; +DROP TABLE IF EXISTS consult_feedback; +DROP TABLE IF EXISTS consult_focus; +DROP TABLE IF EXISTS consult_slots; +DROP TABLE IF EXISTS consult_schedule_days; +DROP TABLE IF EXISTS consult_doctors; +DROP TABLE IF EXISTS consult_business_scopes; +DROP TABLE IF EXISTS consult_protocols; +DROP TABLE IF EXISTS consult_news; +DROP TABLE IF EXISTS consult_banners; +DROP TABLE IF EXISTS consult_user_ext; diff --git a/apps/api/migrations/000059_consult_native.up.sql b/apps/api/migrations/000059_consult_native.up.sql new file mode 100644 index 0000000..9de5494 --- /dev/null +++ b/apps/api/migrations/000059_consult_native.up.sql @@ -0,0 +1,259 @@ +-- ECR-050: miniprogram consult domain (native PostgreSQL) + +CREATE TABLE IF NOT EXISTS consult_user_ext ( + user_id uuid PRIMARY KEY REFERENCES users(id), + stay_period varchar(64) NULL, + id_card varchar(32) NULL, + real_name varchar(64) NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS consult_banners ( + id bigserial PRIMARY KEY, + banner_name varchar(128) NOT NULL DEFAULT '', + banner_image text NOT NULL DEFAULT '', + click_url text NOT NULL DEFAULT '', + describe text NOT NULL DEFAULT '', + order_index int NOT NULL DEFAULT 0, + jump_type int NOT NULL DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS consult_news ( + id bigserial PRIMARY KEY, + type int NOT NULL, + title varchar(255) NOT NULL DEFAULT '', + show_image text NOT NULL DEFAULT '', + content text NOT NULL DEFAULT '', + show_main int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS consult_protocols ( + id bigserial PRIMARY KEY, + code varchar(128) NOT NULL UNIQUE, + title varchar(255) NOT NULL DEFAULT '', + context text NOT NULL DEFAULT '', + status int NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS consult_business_scopes ( + code varchar(64) PRIMARY KEY, + name varchar(64) NOT NULL +); + +CREATE TABLE IF NOT EXISTS consult_doctors ( + id bigserial PRIMARY KEY, + name varchar(64) NOT NULL, + avatar text NOT NULL DEFAULT '', + business_scope varchar(255) NOT NULL DEFAULT '', + cover_url text NOT NULL DEFAULT '', + consultation_method varchar(128) NOT NULL DEFAULT 'online', + education varchar(128) NOT NULL DEFAULT '', + introduction text NOT NULL DEFAULT '', + resume text NOT NULL DEFAULT '', + notice text NOT NULL DEFAULT '', + tags varchar(255) NOT NULL DEFAULT '', + work_experience varchar(128) NOT NULL DEFAULT '', + work_start_time date NULL, + price int NOT NULL DEFAULT 0, + status int NOT NULL DEFAULT 1, + address varchar(255) NOT NULL DEFAULT '', + address_detail varchar(255) NOT NULL DEFAULT '', + show_service_num int NOT NULL DEFAULT 0, + is_top int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS consult_schedule_days ( + id bigserial PRIMARY KEY, + doctor_id bigint NOT NULL REFERENCES consult_doctors(id), + schedule_date date NOT NULL, + UNIQUE (doctor_id, schedule_date) +); + +CREATE TABLE IF NOT EXISTS consult_slots ( + id bigserial PRIMARY KEY, + schedule_id bigint NOT NULL REFERENCES consult_schedule_days(id) ON DELETE CASCADE, + doctor_id bigint NOT NULL REFERENCES consult_doctors(id), + start_time time NOT NULL, + end_time time NOT NULL, + consultation_method varchar(64) NOT NULL DEFAULT 'online', + status int NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_consult_slots_doctor_date ON consult_slots(doctor_id, status); + +CREATE TABLE IF NOT EXISTS consult_focus ( + id bigserial PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id), + doctor_id bigint NOT NULL REFERENCES consult_doctors(id), + status int NOT NULL DEFAULT 1, + read_status int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (user_id, doctor_id) +); + +CREATE TABLE IF NOT EXISTS consult_feedback ( + id bigserial PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id), + content_text text NOT NULL, + contact varchar(32) NOT NULL DEFAULT '', + status int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS consult_tests ( + id bigserial PRIMARY KEY, + test_name varchar(128) NOT NULL, + sub_title varchar(255) NOT NULL DEFAULT '', + test_pic text NOT NULL DEFAULT '', + test_introduction text NOT NULL DEFAULT '', + test_notice text NOT NULL DEFAULT '', + total_num int NOT NULL DEFAULT 0, + actual_num int NOT NULL DEFAULT 0, + show_main int NOT NULL DEFAULT 0, + status int NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS consult_questions ( + id bigserial PRIMARY KEY, + test_id bigint NOT NULL REFERENCES consult_tests(id) ON DELETE CASCADE, + question_type int NOT NULL DEFAULT 1, + question_text text NOT NULL, + question_image text NOT NULL DEFAULT '', + required int NOT NULL DEFAULT 1, + order_index int NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS consult_options ( + id bigserial PRIMARY KEY, + question_id bigint NOT NULL REFERENCES consult_questions(id) ON DELETE CASCADE, + option_text text NOT NULL, + option_score int NOT NULL DEFAULT 0, + order_index int NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS consult_test_results ( + id bigserial PRIMARY KEY, + test_id bigint NOT NULL REFERENCES consult_tests(id) ON DELETE CASCADE, + min_score int NOT NULL DEFAULT 0, + max_score int NOT NULL DEFAULT 0, + result_desc text NOT NULL DEFAULT '', + result_analysis text NOT NULL DEFAULT '', + treat_plan text NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS consult_user_choices ( + id bigserial PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id), + test_id bigint NOT NULL REFERENCES consult_tests(id), + result_id bigint NOT NULL REFERENCES consult_test_results(id), + total_time int NOT NULL DEFAULT 0, + choice_info jsonb NOT NULL DEFAULT '[]', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS consult_orders ( + id bigserial PRIMARY KEY, + order_sn varchar(64) NOT NULL UNIQUE, + slot_id bigint NOT NULL REFERENCES consult_slots(id), + doctor_id bigint NOT NULL REFERENCES consult_doctors(id), + user_id uuid NOT NULL REFERENCES users(id), + appointment_date date NOT NULL, + start_time time NOT NULL, + end_time time NOT NULL, + status int NOT NULL DEFAULT 0, + user_deleted int NOT NULL DEFAULT 0, + appointment_name varchar(64) NOT NULL DEFAULT '', + birthday date NULL, + phone varchar(20) NOT NULL DEFAULT '', + sex int NOT NULL DEFAULT 1, + emergency_contact_info text NOT NULL DEFAULT '', + user_read int NOT NULL DEFAULT 0, + total_amount int NOT NULL DEFAULT 0, + valid_time timestamptz NULL, + pay_time timestamptz NULL, + cancel_time timestamptz NULL, + cancel_flag int NOT NULL DEFAULT 0, + consultation_method varchar(64) NOT NULL DEFAULT '', + pay_param jsonb NULL, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_consult_orders_user ON consult_orders(user_id, user_deleted); + +-- seed: scopes, protocol, banner, news, doctor, test, 14-day slots +INSERT INTO consult_business_scopes(code, name) VALUES + ('emotion', '情绪压力'), + ('relation', '亲密关系'), + ('career', '职业发展') +ON CONFLICT (code) DO NOTHING; + +INSERT INTO consult_protocols(code, title, context, status) VALUES + ('consultation_appointment_agreement', '咨询预约协议', '

预约即表示你已阅读并同意本协议。本服务不替代危机干预或医疗诊断。

', 1), + ('psychological_show_appointment_agreement', '心理咨询展示与预约说明', '

咨询师信息仅供预约参考。如需紧急帮助请联系当地心理援助热线。

', 1), + ('privacy_protection_agreement', '愈心谷咨询小程序隐私保护指引', '

我们仅在提供服务所必需的范围内处理你的手机号与预约信息。

', 1) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO consult_banners(banner_name, banner_image, click_url, describe, order_index, jump_type) +SELECT '愈心谷', '/static/n-main/yxg-brand-logo.png', '', '欢迎来到愈心谷', 1, 1 +WHERE NOT EXISTS (SELECT 1 FROM consult_banners); + +INSERT INTO consult_news(type, title, show_image, content, show_main) +SELECT 1, '艺术疗愈入门', '/static/n-main/yxg-brand-logo.png', '

用创作看见自己的情绪。

', 1 +WHERE NOT EXISTS (SELECT 1 FROM consult_news WHERE type=1); + +INSERT INTO consult_news(type, title, show_image, content, show_main) +SELECT 2, '本周活动', '/static/n-main/yxg-brand-logo.png', '

线上主题分享,欢迎预约咨询师一对一。

', 1 +WHERE NOT EXISTS (SELECT 1 FROM consult_news WHERE type=2); + +INSERT INTO consult_doctors(name, avatar, business_scope, consultation_method, education, introduction, resume, notice, tags, work_experience, work_start_time, price, status, address, address_detail, show_service_num, is_top) +SELECT '林安', '/static/n-main/yxg-brand-logo.png', 'emotion,relation', 'online,face_to_face', '应用心理硕士', + '关注情绪调节与关系议题', '国家二级心理咨询师', '请提前10分钟进入会谈。', '情绪,关系', + '8年', DATE '2018-03-01', 22600, 1, '线上/线下', '预约成功后发送地址', 128, 1 +WHERE NOT EXISTS (SELECT 1 FROM consult_doctors); + +INSERT INTO consult_schedule_days(doctor_id, schedule_date) +SELECT d.id, (CURRENT_DATE + g.n) +FROM consult_doctors d +CROSS JOIN generate_series(1, 14) AS g(n) +ON CONFLICT (doctor_id, schedule_date) DO NOTHING; + +INSERT INTO consult_slots(schedule_id, doctor_id, start_time, end_time, consultation_method, status) +SELECT s.id, s.doctor_id, t.st, t.et, 'online', 0 +FROM consult_schedule_days s +CROSS JOIN (VALUES (TIME '09:00', TIME '10:00'), (TIME '14:00', TIME '15:00')) AS t(st, et) +WHERE NOT EXISTS ( + SELECT 1 FROM consult_slots x WHERE x.schedule_id=s.id AND x.start_time=t.st +); + +INSERT INTO consult_tests(test_name, sub_title, test_pic, test_introduction, test_notice, total_num, show_main, status) +SELECT '情绪小测', '用两分钟看看最近的状态', '/static/n-main/yxg-brand-logo.png', + '本测评仅供自我觉察,不是诊断。', '请按第一直觉作答。', 1280, 1, 1 +WHERE NOT EXISTS (SELECT 1 FROM consult_tests); + +INSERT INTO consult_questions(test_id, question_type, question_text, required, order_index) +SELECT t.id, 1, '最近两周,我感到紧张或坐立不安。', 1, 1 +FROM consult_tests t +WHERE t.test_name='情绪小测' AND NOT EXISTS (SELECT 1 FROM consult_questions q WHERE q.test_id=t.id); + +INSERT INTO consult_questions(test_id, question_type, question_text, required, order_index) +SELECT t.id, 1, '最近两周,我仍然能享受日常小事。', 1, 2 +FROM consult_tests t +WHERE t.test_name='情绪小测' AND (SELECT COUNT(*) FROM consult_questions q WHERE q.test_id=t.id) < 2; + +INSERT INTO consult_options(question_id, option_text, option_score, order_index) +SELECT q.id, o.txt, o.sc, o.ord +FROM consult_questions q +JOIN consult_tests t ON t.id=q.test_id AND t.test_name='情绪小测' +CROSS JOIN (VALUES ('很少', 0, 1), ('有时', 1, 2), ('经常', 2, 3)) AS o(txt, sc, ord) +WHERE NOT EXISTS (SELECT 1 FROM consult_options x WHERE x.question_id=q.id); + +INSERT INTO consult_test_results(test_id, min_score, max_score, result_desc, result_analysis, treat_plan) +SELECT t.id, 0, 2, '状态平稳', '你最近的情绪波动在可调节范围内。', '保持作息,需要时可以和咨询师聊聊。' +FROM consult_tests t WHERE t.test_name='情绪小测' +AND NOT EXISTS (SELECT 1 FROM consult_test_results r WHERE r.test_id=t.id AND r.min_score=0); + +INSERT INTO consult_test_results(test_id, min_score, max_score, result_desc, result_analysis, treat_plan) +SELECT t.id, 3, 8, '需要被看见', '最近的紧绷感偏高,适合做一次梳理。', '建议预约咨询,或先做呼吸放松。' +FROM consult_tests t WHERE t.test_name='情绪小测' +AND NOT EXISTS (SELECT 1 FROM consult_test_results r WHERE r.test_id=t.id AND r.min_score=3); diff --git a/docs/BACKEND_DESIGN/BD-2026-049-wechat-auth-gateway.md b/docs/BACKEND_DESIGN/BD-2026-049-wechat-auth-gateway.md new file mode 100644 index 0000000..3b7ced2 --- /dev/null +++ b/docs/BACKEND_DESIGN/BD-2026-049-wechat-auth-gateway.md @@ -0,0 +1,26 @@ +# Backend Design: ECR-049 WeChat Auth + Consult Gateway + +| Field | Value | +|-------|-------| +| ID | BD-2026-049 | +| Status | **Approved** | +| Coding | **In progress** | +| Migration | `000058_wechat_auth_gateway` | + +## Boundary + +```text +POST /api/v1/auth/wechat + { code, encryptedData, iv } → existing SessionResult +users.wx_openid UNIQUE · wx_unionid · java_platform_user_id +user_sessions.java_access_token +ANY /api/app-api/* → JAVA_BASE/app-api/* (Go Bearer → Java Bearer) +ANY /api/admin-api/* → JAVA_BASE/admin-api/* +Java POST /app-api/oauth/internal/issue-mini-app (X-Internal-Key) +Identity: wx_openid = t_platform_user_account.account (mini_app) + phone = t_platform_user.mobile +``` + +## Out + +Module migrate · Java decommission · Admin auth · auto ECR-050 diff --git a/docs/BACKEND_DESIGN/BD-2026-050-consult-native.md b/docs/BACKEND_DESIGN/BD-2026-050-consult-native.md new file mode 100644 index 0000000..806776b --- /dev/null +++ b/docs/BACKEND_DESIGN/BD-2026-050-consult-native.md @@ -0,0 +1,21 @@ +# Backend Design: ECR-050 Consult Native + +| Field | Value | +|-------|-------| +| ID | BD-2026-050 | +| Status | **Approved** | +| Migration | `000059_consult_native` | + +## Boundary + +```text +/api/v1/psychic/banner|news|procotol|study|user-choice|doctor-info|appointment|order|pay|platform-user +/api/v1/upload +PG tables consult_* +WeChat JSAPI unifiedorder when PAY_* set +Remove /api/app-api proxy +``` + +## Out + +Live Java shutdown · MySQL 全量灌数(另做运维脚本)· Admin diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a0d9b73..29a76db 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,10 @@ # CHANGELOG — ESS process artifacts +## 2026-09-14 + +- **ECR-050 Approved · Coding:** 咨询/测评/订单/支付迁入 Go(migration `000059`)· 去掉 Java 反代 · Human「阶段3,4一起实现再测试」· **禁自动 ECR-051** +- **ECR-049 Approved · Coding:** 微信小程序登录 + 咨询过渡反代(ADR-0008 · migration `000058` · 不迁咨询业务)· Human「按这个执行」· **禁自动 ECR-050** + ## 2026-08-13 - **ECR-048 Closed:** ReportTemplate 薄写(复用 `admin.growth.write` POST/PUT · migration `000057` · 无新 C 端)· **Loop STOP** · 禁自动 ECR-049 · 无 Funnel/Prompt diff --git a/docs/CONTRACT_DIFF/ECR-049.yaml b/docs/CONTRACT_DIFF/ECR-049.yaml new file mode 100644 index 0000000..7e14d4e --- /dev/null +++ b/docs/CONTRACT_DIFF/ECR-049.yaml @@ -0,0 +1,18 @@ +ecr: ECR-049 +capability: account-auth +change: + type: additive +breaking_change: false +migration_required: true +apis: + - method: POST + path: /api/v1/auth/wechat + change: added + - method: ANY + path: /api/app-api/* + change: added + - method: ANY + path: /api/admin-api/* + change: added +migration: 000058_wechat_auth_gateway +notes: H5 OpenLogin kept; miniprogram password login unused; Java remains until later ECR diff --git a/docs/CONTRACT_DIFF/ECR-050.yaml b/docs/CONTRACT_DIFF/ECR-050.yaml new file mode 100644 index 0000000..688a951 --- /dev/null +++ b/docs/CONTRACT_DIFF/ECR-050.yaml @@ -0,0 +1,18 @@ +ecr: ECR-050 +capability: consult-miniprogram +change: + type: additive +breaking_change: true +migration_required: true +apis: + - method: ANY + path: /api/v1/psychic/* + change: added + - method: POST + path: /api/v1/upload + change: added + - method: ANY + path: /api/app-api/* + change: removed +migration: 000059_consult_native +notes: miniprogram urls switched to /api/v1; Java proxy removed diff --git a/docs/ECR/ECR-049-wechat-auth-gateway.md b/docs/ECR/ECR-049-wechat-auth-gateway.md new file mode 100644 index 0000000..6dbe44f --- /dev/null +++ b/docs/ECR/ECR-049-wechat-auth-gateway.md @@ -0,0 +1,25 @@ +# ECR-049 + +**Canonical-ID:** `ECR-049` +**Title:** 微信小程序登录 + 咨询过渡网关 +**Status:** **Approved · Coding** +**Auth:** `ECR-049_WRITE_AUTHORIZATION.md` + +## Change + +1. Spec `account-auth`:小程序登录改为微信;游客仍不可出报告 +2. ADR-0008:Go 为唯一用户后台;Java 过渡期为被调方 +3. `POST /api/v1/auth/wechat` + migration `000058` +4. Go 反代 `/api/app-api/*`、`/api/admin-api/*` 到 Java(带内网签发的 Java Bearer) +5. 小程序(另仓)收成单 token / 单 base / 唯一 `login-pop` + +## Forbidden + +咨询/测评/订单/支付迁库 · 下线 Java/MySQL · 改 Admin 登录 · Write-Wave 续刀 · 自动 ECR-050 + +## Acceptance + +- 微信 `code` 只打 Go 一次;咨询接口经反代仍可用 +- 同一 openid/手机号在魔方与咨询认同一人 +- 未配置微信密钥时接口明确失败,不回落到账密 +- `repo-governance-check.py` PASS · Max Migration=`000058` diff --git a/docs/ECR/ECR-050-consult-native.md b/docs/ECR/ECR-050-consult-native.md new file mode 100644 index 0000000..e4e8987 --- /dev/null +++ b/docs/ECR/ECR-050-consult-native.md @@ -0,0 +1,24 @@ +# ECR-050 + +**Canonical-ID:** `ECR-050` +**Title:** 咨询域迁入 Go + 去掉 Java 反代 +**Status:** **Approved · Coding** +**Auth:** `ECR-050_WRITE_AUTHORIZATION.md` + +## Change + +1. Spec `consult-miniprogram`:小程序咨询/测评/内容/订单/支付由 Go+PG 原生提供 +2. Migration `000059`:咨询域表 + 可测种子数据 +3. 路径 `/api/v1/psychic/*` 对齐原 Java VO +4. 小程序 `apiUrl.js` 全部切到 `/api/v1`;删除 Go 对 Java `/api/app-api` 反代 +5. 微信支付:有商户密钥走统一下单;无密钥返回 mock 参数(dev) + +## Forbidden + +自动 ECR-051 · Admin 登录 · 在本机关现网 Java + +## Acceptance + +- 小程序 MUST 路径均有 Go 实现 +- `go test` / `go build` / governance PASS +- 未配置微信商户时下单不 500 diff --git a/docs/HANDOFF/ECR-049-architect-to-engineer.md b/docs/HANDOFF/ECR-049-architect-to-engineer.md new file mode 100644 index 0000000..ac2028e --- /dev/null +++ b/docs/HANDOFF/ECR-049-architect-to-engineer.md @@ -0,0 +1,3 @@ +# HANDOFF — Architect → Engineer · ECR-049 + +Auth Approved · Spec/ADR/BD Ready · WeChat login + Java issue-by-openid + `/api/app-api` proxy · migration 000058 · 不迁咨询业务 · Closed → STOP. diff --git a/docs/HANDOFF/ECR-049-engineer-to-reviewer.md b/docs/HANDOFF/ECR-049-engineer-to-reviewer.md new file mode 100644 index 0000000..671d447 --- /dev/null +++ b/docs/HANDOFF/ECR-049-engineer-to-reviewer.md @@ -0,0 +1,5 @@ +# HANDOFF — Engineer → Reviewer · ECR-049 + +WeChat `POST /api/v1/auth/wechat` · migration 000058 · consult proxy `/api/app-api/*` · Java `issue-mini-app` · 小程序单 token。 +`go test ./internal/wechat` PASS · governance PASS · 真机未测。 +Forbidden 未破:未迁咨询业务 · 未自动 ECR-050。 diff --git a/docs/HANDOFF/ECR-050-architect-to-engineer.md b/docs/HANDOFF/ECR-050-architect-to-engineer.md new file mode 100644 index 0000000..9f6326f --- /dev/null +++ b/docs/HANDOFF/ECR-050-architect-to-engineer.md @@ -0,0 +1,3 @@ +# HANDOFF — Architect → Engineer · ECR-050 + +Auth Approved · Spec consult-miniprogram · migration 000059 · native /api/v1/psychic · 去掉 app-api 反代。 diff --git a/docs/HANDOFF/ECR-050-engineer-to-reviewer.md b/docs/HANDOFF/ECR-050-engineer-to-reviewer.md new file mode 100644 index 0000000..907d3ca --- /dev/null +++ b/docs/HANDOFF/ECR-050-engineer-to-reviewer.md @@ -0,0 +1,4 @@ +# HANDOFF — Engineer → Reviewer · ECR-050 + +Native `/api/v1/psychic/*` · migration 000059 + seed · 小程序 urls 切 `/api/v1` · Java `/api/app-api` 反代已删。 +integration TestConsultNativePublic PASS · 真机未测 · 现网 MySQL 未灌数。 diff --git a/docs/PRODUCT_SPEC/ECR-049-wechat-auth-gateway.md b/docs/PRODUCT_SPEC/ECR-049-wechat-auth-gateway.md new file mode 100644 index 0000000..65ad083 --- /dev/null +++ b/docs/PRODUCT_SPEC/ECR-049-wechat-auth-gateway.md @@ -0,0 +1,5 @@ +# PRODUCT_SPEC — ECR-049 + +小程序 C 端登录改为微信手机号授权;Go 为身份源。 +咨询/测评/订单仍走 Java,经 Go 反代,本刀不迁业务表。 +H5 OpenLogin 过渡保留。Admin 不动。 diff --git a/docs/PROJECT_PROFILE.md b/docs/PROJECT_PROFILE.md index 537187e..35b8a8b 100644 --- a/docs/PROJECT_PROFILE.md +++ b/docs/PROJECT_PROFILE.md @@ -53,17 +53,19 @@ - **本仓:** `ess_intake: strict` · Retro FAIL · 单 ECR Context - **Ops foundation:** `docs/WAVE0/` · `.ai/domain/boundary-rules.md` · `glossary.yaml` · Loop: `docs/WAVE0/LOOP_AUTHORIZATION.md` - ECR: main 线 ECR-006–016 Closed;Ops 扩展 ECR-013A/B · 017–040;分叉已固定为 `ECR-012-star` / `014-plan` / `015-code` / `016-insight`(见 TRACEABILITY) -- **Next ECR / Max Migration:** **Next=`ECR-049`(禁自动开)** · **Max=`000057`** +- **Next ECR / Max Migration:** **Next=`ECR-051`(禁自动开)** · **Max=`000059`** - **Write-Wave CMS:** 041–042 Closed · STOP - **ExploreConfig:** ECR-043…046 Closed · 同构写面 **耗尽 · STOP** · Prompt/Knowledge/Chunk STOP -- **GrowthInsights:** ECR-047 Funnel · ECR-048 ReportTemplate **Closed** · **Loop STOP** · 禁自动 ECR-049 +- **GrowthInsights:** ECR-047 Funnel · ECR-048 ReportTemplate **Closed** · **Loop STOP** +- **Identity:** ECR-049 微信登录 **Implemented** +- **Consult:** ECR-050 咨询域原生 Go **Coding** · Closed 后 STOP · 禁自动 ECR-051 - **Release:** ECR-008 Live Promote **BLOCKED**(独立门禁 · 勿与 Write-Wave 混谈) - EXP: (无) - STATE: `docs/STATE/`(含 ECR-041…048) - TRACEABILITY: `docs/TRACEABILITY.md` · 门禁 `scripts/repo-governance-check.py` - ADR: `.ai/adr/0007-ess-ai-dual-track.md` - Product status: `.ai/product/p1-status.md`(**P1 Complete**)· `.ai/product/p2-status.md`(**P2 Complete**) -- Active Spec: `account-auth` · `explore-test` · `home` · `star-profile` · `life-rhythm` · `input-compliance` · `ops-system` · `profile` · `ops-banner-write` · `ops-star-config-write` · `ops-rhythm-config-write` · `ops-image-card-deck-write` · `ops-scale-definition-write` · `ops-funnel-definition-write` · `ops-report-template-write` +- Active Spec: `account-auth` · `consult-miniprogram` · `explore-test` · `home` · `star-profile` · `life-rhythm` · `input-compliance` · `ops-system` · `profile` · `ops-banner-write` · `ops-star-config-write` · `ops-rhythm-config-write` · `ops-image-card-deck-write` · `ops-scale-definition-write` · `ops-funnel-definition-write` · `ops-report-template-write` ## WIP(尚未单独 ECR) diff --git a/docs/STATE/ECR-049.md b/docs/STATE/ECR-049.md new file mode 100644 index 0000000..5b0b711 --- /dev/null +++ b/docs/STATE/ECR-049.md @@ -0,0 +1,7 @@ +# STATE — ECR-049 + +| Status | **Implemented · Ready Review** | +| Spec | account-auth | +| Migration | 000058_wechat_auth_gateway | +| Owner | REVIEWER | +| Next | STOP · ECR-050 禁自动开 | diff --git a/docs/STATE/ECR-050.md b/docs/STATE/ECR-050.md new file mode 100644 index 0000000..7f48e6a --- /dev/null +++ b/docs/STATE/ECR-050.md @@ -0,0 +1,7 @@ +# STATE — ECR-050 + +| Status | **Implemented · Ready Review** | +| Spec | consult-miniprogram | +| Migration | 000059_consult_native | +| Owner | ENGINEER | +| Next | STOP · ECR-051 禁自动开 | diff --git a/docs/TEST_REPORT/ECR-049.md b/docs/TEST_REPORT/ECR-049.md new file mode 100644 index 0000000..72728fd --- /dev/null +++ b/docs/TEST_REPORT/ECR-049.md @@ -0,0 +1,13 @@ +# TEST_REPORT — ECR-049 + +Date: 2026-09-14 + +| Check | Result | +|-------|--------| +| `python scripts/repo-governance-check.py` | PASS · max 000058 · Next ECR-050 | +| `go test ./internal/wechat` | PASS(AES 手机号解密往返) | +| `go build ./...`(apps/api) | PASS | +| 微信真机 `code` + 咨询反代 | **未测**(需 AppId/Secret + Java `auth.internal-issue-key` + 现网网关) | +| 小程序微信开发者工具点选 | **未测**(本环境无该工具) | + +未做:咨询业务迁库、支付、下线 Java。 diff --git a/docs/TEST_REPORT/ECR-050.md b/docs/TEST_REPORT/ECR-050.md new file mode 100644 index 0000000..aed05bb --- /dev/null +++ b/docs/TEST_REPORT/ECR-050.md @@ -0,0 +1,14 @@ +# TEST_REPORT — ECR-050 + +Date: 2026-09-14 + +| Check | Result | +|-------|--------| +| `repo-governance-check.py` | PASS · max 000059 | +| `go test ./internal/wechatpay` | PASS | +| `go test ./internal/wechat` | PASS | +| `go test ./internal/integration -run TestConsultNativePublic\|TestAuthLogout` | PASS | +| `go build ./...` | PASS | +| 微信开发者工具点选 | **未测** | + +未做:现网 MySQL 灌数、现网关 Java 进程。 diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 46638e4..80e4ea5 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -4,8 +4,8 @@ | Anchor | Value | Rule | |--------|-------|------| -| **Next ECR** | `ECR-049` | **冻结:** 须重新 Candidate Review + Human 单独拍板;**禁止自动开刀** | -| **Max Migration** | `000057` | 禁止凭记忆 | +| **Next ECR** | `ECR-051` | **冻结:** 须重新 Candidate Review + Human 单独拍板;**禁止自动开刀** | +| **Max Migration** | `000059` | 禁止凭记忆 | - ECR identity **全局唯一**(含已 Closed);禁止同号双义。分叉只用 `ECR-NNN-suffix` / `ECR-NNNA`。 - Migration **6 位版本号唯一**。撞号修复见 `docs/MIGRATION_RENUMBER.md`。 @@ -74,5 +74,7 @@ | ECR-046 | ExploreConfig · ScaleDefinition 元数据薄写 | **Closed** | Spec ops-scale-definition-write · BD-2026-046 · migration **000055** · status 仍 ECR-008 · Closed 后 STOP · 禁自动 ECR-047 | | ECR-047 | GrowthInsights · FunnelDefinition 薄写面 | **Closed · Authorized · Pushed · FROZEN WAIT** | Spec ops-funnel-definition-write · BD-2026-047 · migration **000056** · Human authorize · Freeze `ECR-047_FREEZE_REVIEW.md` · origin=`aa197b7` · **禁自动 ECR-048** | | ECR-048 | GrowthInsights · ReportTemplate 薄写面 | **Closed** | Spec ops-report-template-write · BD-2026-048 · migration **000057** · Closed 后 STOP · 禁自动 ECR-049 | +| ECR-049 | 微信小程序登录 + 咨询过渡网关 | **Implemented** | Spec account-auth · ADR-0008 · BD-2026-049 · migration **000058** | +| ECR-050 | 咨询域迁入 Go + 去掉 Java 反代 | **Approved · Coding** | Spec consult-miniprogram · BD-2026-050 · migration **000059** · Closed 后 STOP · 禁自动 ECR-051 | > **Migration:** 合并后 `000015`–`000023` 曾撞号,已重编号至 `000050`。以 `apps/api/migrations/` 与 `docs/MIGRATION_RENUMBER.md` 为准(文档中旧号引用可能滞后)。 diff --git a/docs/WAVE0/ECR-049-CANDIDATE_REVIEW.md b/docs/WAVE0/ECR-049-CANDIDATE_REVIEW.md new file mode 100644 index 0000000..39669d9 --- /dev/null +++ b/docs/WAVE0/ECR-049-CANDIDATE_REVIEW.md @@ -0,0 +1,16 @@ +# Candidate Review — ECR-049 + +> 首刀 = **微信登录 + 咨询反代(身份源切 Go)** · Human 2026-09-14 拍板 + +**Anchors:** Next=`ECR-049`(Human 单独开刀,非 Write-Wave 自动续)· Max Migration=`000057` → 编码占 `000058` + +| # | 问题 | 结论 | +|---|---|---| +| 1 | 是否新 C 端行为? | 是 · 小程序微信登录打 Go;咨询请求经 Go 反代 | +| 2 | 是否新 API / 新表? | 是 · `POST /api/v1/auth/wechat` · `users.wx_*` · 反代 `/api/app-api/*` | +| 3 | 是否换栈? | 否 · 仍 Go + PG;Java 过渡期保留 | +| 4 | 是否迁咨询业务? | **否** · 本刀只做身份 + 网关 | +| 5 | H5 账密? | 过渡保留 OpenLogin,避免 H5 断服;小程序不再走账密 | +| 6 | 是否需要 migration? | **是** · 占 **000058** | + +Closed → STOP · 禁自动 ECR-050 diff --git a/docs/WAVE0/ECR-049_WRITE_AUTHORIZATION.md b/docs/WAVE0/ECR-049_WRITE_AUTHORIZATION.md new file mode 100644 index 0000000..6666add --- /dev/null +++ b/docs/WAVE0/ECR-049_WRITE_AUTHORIZATION.md @@ -0,0 +1,21 @@ +# Write Authorization — ECR-049 only + +| Field | Value | +|-------|-------| +| Date | 2026-09-14 | +| Authorizer | Human(会话:「按这个执行」合并后台 + 仅微信登录 + 小程序统一接入) | +| Status | **Approved · Coding** | +| Scope | 微信小程序登录 + 咨询过渡反代 + 身份映射 | +| Continuous Loop | **STOP** · 本刀不是 Write-Wave 续刀 | +| After Closed | **STOP** · 禁止自动 ECR-050 | + +## Human 拍板(2026-09-14) + +1. 真正合库合服务:以 Go + PostgreSQL 吸收咨询身份,Java 过渡期为被调方。 +2. C 端登录只走微信手机号授权;关掉小程序魔方账密页。 +3. 小程序只接一个后台入口(单域名 / 单 token)。 +4. 本 ECR **不**迁咨询/测评/支付业务表,**不**下线 Java。 + +## Hard STOP + +Write-Wave / Funnel / Prompt · 支付迁库 · 咨询档期迁库 · 自动 ECR-050 · ECR-008 Live Promote 解禁 · 改 Admin 登录 diff --git a/docs/WAVE0/ECR-050_WRITE_AUTHORIZATION.md b/docs/WAVE0/ECR-050_WRITE_AUTHORIZATION.md new file mode 100644 index 0000000..19a8b28 --- /dev/null +++ b/docs/WAVE0/ECR-050_WRITE_AUTHORIZATION.md @@ -0,0 +1,14 @@ +# Write Authorization — ECR-050 only + +| Field | Value | +|-------|-------| +| Date | 2026-09-14 | +| Authorizer | Human(会话:「阶段3,4都一起实现了再测试」) | +| Status | **Approved · Coding** | +| Scope | 咨询/测评/内容/订单/支付迁入 Go · 去掉 Java 反代 | +| Continuous Loop | **STOP** | +| After Closed | **STOP** · 禁止自动 ECR-051 | + +## Hard STOP + +Write-Wave · 改 Admin 登录 · 自动 ECR-051 · 真机关闭现网 Java 进程(须运维另做) diff --git a/scripts/import_java_consult.py b/scripts/import_java_consult.py new file mode 100644 index 0000000..370dcd6 --- /dev/null +++ b/scripts/import_java_consult.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Pull public consult content + images from live Java into local PostgreSQL.""" + +from __future__ import annotations + +import json +import os +import re +import ssl +import sys +import urllib.error +import urllib.request +from pathlib import Path +from urllib.parse import quote, unquote, urlparse + +JAVA = os.environ.get("JAVA_CONSULT_BASE", "https://miniapp.yuxingu.com.cn") +PG_DSN = os.environ.get( + "DATABASE_URL", + "postgres://yuxingu:yuxingu@127.0.0.1:5432/yuxingu?sslmode=disable", +) +ROOT = Path(__file__).resolve().parents[1] / "apps" / "api" / "data" / "yxg-mp" +CTX = ssl.create_default_context() + +IMG_RE = re.compile(r"https://miniapp\.yuxingu\.com\.cn/yxg-mp/[^\\\"'\s)]+", re.I) + + +def api(path: str, method: str = "GET") -> dict: + url = JAVA.rstrip("/") + path + req = urllib.request.Request(url, method=method, headers={"Content-Type": "application/json"}) + if method == "POST": + req.data = b"{}" + with urllib.request.urlopen(req, timeout=30, context=CTX) as resp: + return json.load(resp) + + +def get_data(path: str, method: str = "GET"): + body = api(path, method) + if str(body.get("code")) not in ("0", "0.0"): + raise RuntimeError(f"{path} code={body.get('code')} msg={body.get('msg')}") + return body.get("data") + + +def collect_urls(*blobs: object) -> set[str]: + found: set[str] = set() + for blob in blobs: + found.update(IMG_RE.findall(json.dumps(blob, ensure_ascii=False))) + return found + + +def download(url: str) -> None: + url = url.rstrip("\\").rstrip() + parsed = urlparse(url) + rel = unquote(parsed.path) + if not rel.startswith("/yxg-mp/"): + return + dest = ROOT / rel[len("/yxg-mp/") :] + dest.parent.mkdir(parents=True, exist_ok=True) + if dest.exists() and dest.stat().st_size > 0: + return + fetch = f"{parsed.scheme}://{parsed.netloc}{quote(rel, safe='/@')}" + req = urllib.request.Request(fetch, headers={"User-Agent": "yxg-import/1"}) + with urllib.request.urlopen(req, timeout=60, context=CTX) as resp, dest.open("wb") as out: + out.write(resp.read()) + print("saved", dest.relative_to(ROOT.parent), dest.stat().st_size) + + +def main() -> int: + try: + import psycopg + except ImportError: + os.system(f"{sys.executable} -m pip install 'psycopg[binary]' -q") + import psycopg + + banners = get_data("/app-api/psychic/banner/all", "POST") or [] + news_items = [] + for typ in (1, 2): + page = get_data(f"/app-api/psychic/news/page?type={typ}&pageNo=1&pageSize=50") or {} + for row in page.get("list") or []: + detail = get_data(f"/app-api/psychic/news/get?id={row['id']}") or row + news_items.append(detail) + doctors = [] + page = get_data("/app-api/psychic/doctor-info/page?pageNo=1&pageSize=50") or {} + top_ids = { + str(x.get("id")) + for x in (get_data("/app-api/psychic/doctor-info/page?pageNo=1&pageSize=50&isTop=1") or {}).get("list") or [] + } + for row in page.get("list") or []: + detail = get_data(f"/app-api/psychic/doctor-info/get?id={row['id']}") or row + detail["_is_top"] = 1 if str(detail.get("id")) in top_ids else 0 + doctors.append(detail) + tests = get_data("/app-api/psychic/study/list", "POST") or [] + scopes = get_data("/app-api/psychic/doctor-info/business-scope-list") or [] + + urls = collect_urls(banners, news_items, doctors, tests) + print(f"content banners={len(banners)} news={len(news_items)} doctors={len(doctors)} tests={len(tests)} images={len(urls)}") + ROOT.mkdir(parents=True, exist_ok=True) + for url in sorted(urls): + try: + download(url) + except urllib.error.HTTPError as e: + print("skip", url, e.code) + except Exception as e: + print("skip", url, e) + + with psycopg.connect(PG_DSN) as conn: + conn.execute( + """ + TRUNCATE consult_slots, consult_schedule_days, consult_focus, + consult_orders, consult_user_choices, consult_options, + consult_questions, consult_test_results, consult_tests, + consult_doctors, consult_news, consult_banners, consult_business_scopes + RESTART IDENTITY CASCADE + """ + ) + for s in scopes: + conn.execute( + "INSERT INTO consult_business_scopes(code, name) VALUES (%s, %s) ON CONFLICT (code) DO UPDATE SET name=EXCLUDED.name", + (s.get("code") or "", s.get("name") or ""), + ) + for b in banners: + conn.execute( + """INSERT INTO consult_banners(id, banner_name, banner_image, click_url, describe, order_index, jump_type) + VALUES (%s,%s,%s,%s,%s,%s,%s)""", + ( + int(b["id"]), + b.get("bannerName") or "", + b.get("bannerImage") or "", + b.get("clickUrl") or "", + b.get("describe") or "", + int(b.get("orderIndex") or 0), + int(b.get("jumpType") or 1), + ), + ) + for n in news_items: + conn.execute( + """INSERT INTO consult_news(id, type, title, show_image, content, show_main) + VALUES (%s,%s,%s,%s,%s,%s)""", + ( + int(n["id"]), + int(n.get("type") or 0), + n.get("title") or "", + n.get("showImage") or "", + n.get("content") or "", + int(n.get("showMain") or 0), + ), + ) + for d in doctors: + conn.execute( + """INSERT INTO consult_doctors( + id, name, avatar, business_scope, cover_url, consultation_method, education, + introduction, resume, notice, tags, work_experience, work_start_time, price, + status, address, address_detail, is_top) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,1,%s,%s,%s)""", + ( + int(d["id"]), + d.get("name") or "", + d.get("avatar") or "", + d.get("businessScope") or "", + d.get("coverUrl") or d.get("avatar") or "", + d.get("consultationMethod") or "online", + d.get("education") or "", + d.get("introduction") or "", + d.get("resume") or "", + d.get("notice") or "", + d.get("tags") or "", + d.get("workExperience") or "", + d.get("workStartTime") or None, + int(d.get("price") or 0), + d.get("address") or "", + d.get("addressDetail") or "", + int(d.get("_is_top") or 0), + ), + ) + for t in tests: + conn.execute( + """INSERT INTO consult_tests(id, test_name, sub_title, test_pic, total_num, show_main, status) + VALUES (%s,%s,%s,%s,%s,%s,1)""", + ( + int(t["id"]), + t.get("testName") or "", + t.get("subTitle") or "", + t.get("testPic") or "", + int(t.get("totalNum") or 0), + int(t.get("showMain") or 0), + ), + ) + conn.execute( + """ + INSERT INTO consult_schedule_days(doctor_id, schedule_date) + SELECT d.id, (CURRENT_DATE + g.n) + FROM consult_doctors d + CROSS JOIN generate_series(1, 14) AS g(n) + ON CONFLICT (doctor_id, schedule_date) DO NOTHING + """ + ) + conn.execute( + """ + INSERT INTO consult_slots(schedule_id, doctor_id, start_time, end_time, consultation_method, status) + SELECT s.id, s.doctor_id, t.st, t.et, 'online', 0 + FROM consult_schedule_days s + CROSS JOIN (VALUES (TIME '09:00', TIME '10:00'), (TIME '14:00', TIME '15:00')) AS t(st, et) + WHERE NOT EXISTS ( + SELECT 1 FROM consult_slots x WHERE x.schedule_id=s.id AND x.start_time=t.st + ) + """ + ) + conn.execute( + """ + INSERT INTO consult_tests(test_name, sub_title, test_pic, test_introduction, test_notice, total_num, show_main, status) + SELECT '情绪小测', '用两分钟看看最近的状态', + 'https://miniapp.yuxingu.com.cn/yxg-mp/2025/06/27/生活满意度指数A_0.jpg', + '本测评仅供自我觉察,不是诊断。', '请按第一直觉作答。', 1280, 1, 1 + WHERE NOT EXISTS (SELECT 1 FROM consult_tests WHERE test_name='情绪小测') + """ + ) + conn.execute( + """ + INSERT INTO consult_questions(test_id, question_type, question_text, required, order_index) + SELECT t.id, 1, q.txt, 1, q.ord + FROM consult_tests t + CROSS JOIN (VALUES + ('最近两周,我感到紧张或坐立不安。', 1), + ('最近两周,我仍然能享受日常小事。', 2) + ) AS q(txt, ord) + WHERE t.test_name='情绪小测' + AND NOT EXISTS (SELECT 1 FROM consult_questions x WHERE x.test_id=t.id) + """ + ) + conn.execute( + """ + INSERT INTO consult_options(question_id, option_text, option_score, order_index) + SELECT q.id, o.txt, o.sc, o.ord + FROM consult_questions q + JOIN consult_tests t ON t.id=q.test_id AND t.test_name='情绪小测' + CROSS JOIN (VALUES ('很少', 0, 1), ('有时', 1, 2), ('经常', 2, 3)) AS o(txt, sc, ord) + WHERE NOT EXISTS (SELECT 1 FROM consult_options x WHERE x.question_id=q.id) + """ + ) + conn.execute( + """ + INSERT INTO consult_test_results(test_id, min_score, max_score, result_desc, result_analysis, treat_plan) + SELECT t.id, r.a, r.b, r.d, r.an, r.p + FROM consult_tests t + CROSS JOIN (VALUES + (0, 2, '状态平稳', '你最近的情绪波动在可调节范围内。', '保持作息,需要时可以和咨询师聊聊。'), + (3, 8, '需要被看见', '最近的紧绷感偏高,适合做一次梳理。', '建议预约咨询,或先做呼吸放松。') + ) AS r(a,b,d,an,p) + WHERE t.test_name='情绪小测' + AND NOT EXISTS (SELECT 1 FROM consult_test_results x WHERE x.test_id=t.id) + """ + ) + conn.execute("SELECT setval(pg_get_serial_sequence('consult_banners', 'id'), COALESCE((SELECT MAX(id) FROM consult_banners), 1))") + conn.execute("SELECT setval(pg_get_serial_sequence('consult_news', 'id'), COALESCE((SELECT MAX(id) FROM consult_news), 1))") + conn.execute("SELECT setval(pg_get_serial_sequence('consult_doctors', 'id'), COALESCE((SELECT MAX(id) FROM consult_doctors), 1))") + conn.execute("SELECT setval(pg_get_serial_sequence('consult_tests', 'id'), COALESCE((SELECT MAX(id) FROM consult_tests), 1))") + conn.commit() + print("import ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())