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
+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);