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+">", ""+tag+">"
+ i := strings.Index(s, open)
+ if i < 0 {
+ open, close = "<"+tag+">"+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())