feat(api): 接入微信登录并原生实现咨询域(ECR-049/050)

小程序可在 Go 上完成微信手机号登录、测评、预约和下单,不再反代 Java。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-09-15 00:26:29 +08:00
co-authored by Cursor
parent db4d118f9a
commit 7155b8b53a
45 changed files with 3013 additions and 27 deletions
+29
View File
@@ -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 单次使用,且拖住前端统一接入)。
+14 -11
View File
@@ -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 | 登录成功后:签发 sessiondevice_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
@@ -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)。
+17
View File
@@ -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:
+85
View File
@@ -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.
+33
View File
@@ -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)
+384
View File
@@ -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, "<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>")
}
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+"><![CDATA[", "]]></"+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]
}
+59
View File
@@ -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)
}
+17 -1
View File
@@ -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)
@@ -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)
}
}
+119
View File
@@ -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)
}
+94 -10
View File
@@ -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
+101
View File
@@ -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
}
@@ -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, &notice)
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, &notice, &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")
}
+84
View File
@@ -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
}
+75
View File
@@ -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
}
+47
View File
@@ -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
}
+158
View File
@@ -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[:]))
}
+17
View File
@@ -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)
}
}
@@ -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;
@@ -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;
@@ -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;
@@ -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', '咨询预约协议', '<p>预约即表示你已阅读并同意本协议。本服务不替代危机干预或医疗诊断。</p>', 1),
('psychological_show_appointment_agreement', '心理咨询展示与预约说明', '<p>咨询师信息仅供预约参考。如需紧急帮助请联系当地心理援助热线。</p>', 1),
('privacy_protection_agreement', '愈心谷咨询小程序隐私保护指引', '<p>我们仅在提供服务所必需的范围内处理你的手机号与预约信息。</p>', 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', '<p>用创作看见自己的情绪。</p>', 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', '<p>线上主题分享,欢迎预约咨询师一对一。</p>', 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);
@@ -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
@@ -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
+5
View File
@@ -1,5 +1,10 @@
# CHANGELOG — ESS process artifacts
## 2026-09-14
- **ECR-050 Approved · Coding** 咨询/测评/订单/支付迁入 Gomigration `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
+18
View File
@@ -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
+18
View File
@@ -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
+25
View File
@@ -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-0008Go 为唯一用户后台;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`
+24
View File
@@ -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
@@ -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.
@@ -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。
@@ -0,0 +1,3 @@
# HANDOFF — Architect → Engineer · ECR-050
Auth Approved · Spec consult-miniprogram · migration 000059 · native /api/v1/psychic · 去掉 app-api 反代。
@@ -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 未灌数。
@@ -0,0 +1,5 @@
# PRODUCT_SPEC — ECR-049
小程序 C 端登录改为微信手机号授权;Go 为身份源。
咨询/测评/订单仍走 Java,经 Go 反代,本刀不迁业务表。
H5 OpenLogin 过渡保留。Admin 不动。
+5 -3
View File
@@ -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-006016 ClosedOps 扩展 ECR-013A/B · 017040;分叉已固定为 `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** 041042 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
+7
View File
@@ -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 禁自动开 |
+7
View File
@@ -0,0 +1,7 @@
# STATE — ECR-050
| Status | **Implemented · Ready Review** |
| Spec | consult-miniprogram |
| Migration | 000059_consult_native |
| Owner | ENGINEER |
| Next | STOP · ECR-051 禁自动开 |
+13
View File
@@ -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` | PASSAES 手机号解密往返) |
| `go build ./...`apps/api | PASS |
| 微信真机 `code` + 咨询反代 | **未测**(需 AppId/Secret + Java `auth.internal-issue-key` + 现网网关) |
| 小程序微信开发者工具点选 | **未测**(本环境无该工具) |
未做:咨询业务迁库、支付、下线 Java。
+14
View File
@@ -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 进程。
+4 -2
View File
@@ -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` 为准(文档中旧号引用可能滞后)。
+16
View File
@@ -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
+21
View File
@@ -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 登录
+14
View File
@@ -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 进程(须运维另做)
+262
View File
@@ -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())