Compare commits
16
Commits
aa197b719b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7155b8b53a | ||
|
|
db4d118f9a | ||
|
|
fc53be22f8 | ||
|
|
e7290ed726 | ||
|
|
db3669c595 | ||
|
|
580ba85b61 | ||
|
|
57687de360 | ||
|
|
f4bd57ce1b | ||
|
|
9d4fb52d29 | ||
|
|
96292d0e95 | ||
|
|
635a83fe11 | ||
|
|
ebc2d3ad80 | ||
|
|
8d856fb56c | ||
|
|
d33a25a663 | ||
|
|
c693ac4bbc | ||
|
|
e0f605558b |
@@ -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 单次使用,且拖住前端统一接入)。
|
||||
@@ -55,7 +55,8 @@
|
||||
| [ops-rhythm-config-write.md](ops-rhythm-config-write.md) | RhythmConfig **写面** | §7 | admin 写 · `GET /rhythm/configs` | ExploreConfig · **ECR-044** |
|
||||
| [ops-image-card-deck.md](ops-image-card-deck.md) | ExploreConfig ImageCardDeck(只读) | §7 | `/admin/explore/image-card-decks*` | Ops · **ECR-037** |
|
||||
| [ops-image-card-deck-write.md](ops-image-card-deck-write.md) | ImageCardDeck **写面** | §7 | admin 写 · `GET /cards/decks` | ExploreConfig · **ECR-045** |
|
||||
| [ops-report-template.md](ops-report-template.md) | GrowthInsights ReportTemplate | §7 | `/admin/growth/report-templates*` | Ops · **ECR-038** |
|
||||
| [ops-report-template.md](ops-report-template.md) | GrowthInsights ReportTemplate(只读) | §7 | `/admin/growth/report-templates*` | Ops · **ECR-038** |
|
||||
| [ops-report-template-write.md](ops-report-template-write.md) | ReportTemplate **写面** | §7 | admin POST/PUT · growth.write | GrowthInsights · **ECR-048** |
|
||||
| [ops-funnel-definition.md](ops-funnel-definition.md) | GrowthInsights FunnelDefinition(只读) | §7 | `/admin/analytics/funnel-definitions*` | Ops · **ECR-039** |
|
||||
| [ops-funnel-definition-write.md](ops-funnel-definition-write.md) | FunnelDefinition **写面** | §7 | admin POST/PUT · growth.write | GrowthInsights · **ECR-047** |
|
||||
| [ops-scale-definition.md](ops-scale-definition.md) | ExploreConfig ScaleDefinition(只读) | §7 | `/admin/explore/scales*` | Ops · **ECR-040** |
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| Name | 账号登录(手机号+密码) |
|
||||
| Name | 账号登录(小程序微信;H5 过渡账密) |
|
||||
| Purpose | 跨设备持久用户身份;未登录不可生成/查看生日衍生结果 |
|
||||
| Business Goal | 数据归属清晰;为会员与深度版付费打底 |
|
||||
| Business Goal | 数据归属清晰;咨询与魔方同一人;为会员与深度版付费打底 |
|
||||
|
||||
| In | Out |
|
||||
|---|---|
|
||||
| 注册 / 登录 / 登出 / me | 微信 OAuth、短信 OTP(后续) |
|
||||
| 小程序微信登录 / 登出 / me | 短信 OTP |
|
||||
| 设备身份绑定到已注册账号 | 游客可看完整报告 |
|
||||
| Bearer Session | 运营 Admin 登录(独立) |
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
```text
|
||||
进入结果页或建档
|
||||
↓
|
||||
未登录? → /login 注册或登录
|
||||
未登录? → 小程序 login-pop(微信)或 H5 /login(过渡账密)
|
||||
↓
|
||||
Device 绑定到账号 User
|
||||
↓
|
||||
@@ -58,7 +58,8 @@ Device 绑定到账号 User
|
||||
|
||||
| 路由 | 页面 |
|
||||
|---|---|
|
||||
| `/login` | LoginPage(注册/登录切换) |
|
||||
| `/login` | H5 LoginPage(过渡账密;小程序已删除此页) |
|
||||
| 小程序 `login-pop` | 微信手机号授权(唯一 C 端登录) |
|
||||
| `/mine` | 展示账号手机号尾号 + 退出 |
|
||||
|
||||
---
|
||||
@@ -77,8 +78,8 @@ Device 绑定到账号 User
|
||||
|
||||
| ID | Rule |
|
||||
|---|---|
|
||||
| R1 | **临时开放:** 任意非空手机号 + 任意密码均可登录;未注册则自动建号写入 DB |
|
||||
| R2 | 登录/注册统一走 OpenLogin;不校验历史密码,仍会更新 password_hash 记录本次输入 |
|
||||
| R1 | **小程序:** 微信 `code` + 手机号授权登录;按 `wx_openid` 命中,否则按手机号合并历史账密账号;未命中则建号 |
|
||||
| R2 | **H5 过渡:** OpenLogin(任意非空手机号+密码)仍可用,避免 H5 断服;小程序不再提供账密页。正式关掉账密须另改本 Spec |
|
||||
| R3 | 登录成功后:签发 session;device_identities.user_id 改绑到账号 |
|
||||
| R4 | 生成/查看档案与报告 API 必须已注册(users.phone 非空)且有效 session 或已绑设备账号 |
|
||||
| R5 | 登出作废 session;设备可再登录其他账号 |
|
||||
@@ -91,7 +92,7 @@ Device 绑定到账号 User
|
||||
|
||||
## 8. 数据模型影响
|
||||
|
||||
- `users.phone` UNIQUE · `users.password_hash` · `users.nickname` · `users.avatar_url`
|
||||
- `users.phone` UNIQUE · `users.wx_openid` UNIQUE · `users.wx_unionid` · `users.java_platform_user_id` · `users.password_hash` · `users.nickname` · `users.avatar_url`
|
||||
- `user_sessions(token, user_id, expires_at)`
|
||||
- 本地文件:`data/avatars/{user_id}.{jpg|png|webp}`(进程相对路径)
|
||||
|
||||
@@ -101,8 +102,9 @@ Device 绑定到账号 User
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/auth/register` | 注册(可带 nickname) |
|
||||
| POST | `/api/v1/auth/login` | 登录 |
|
||||
| POST | `/api/v1/auth/wechat` | 小程序微信登录 `{ code, encryptedData, iv }` |
|
||||
| POST | `/api/v1/auth/register` | H5 过渡注册(可带 nickname) |
|
||||
| POST | `/api/v1/auth/login` | H5 过渡登录 |
|
||||
| POST | `/api/v1/auth/logout` | 登出 |
|
||||
| GET | `/api/v1/auth/me` | 当前账号(含 avatar_url) |
|
||||
| PATCH | `/api/v1/auth/me` | 更新昵称 `{ nickname }` |
|
||||
@@ -158,4 +160,5 @@ Device 绑定到账号 User
|
||||
## 13. AI 开发前检查
|
||||
|
||||
- [x] lexicon:登录/注册/账号
|
||||
- [x] 不碰 Admin Auth
|
||||
- [x] 不碰 Admin Auth
|
||||
- [x] 咨询域见 Spec `consult-miniprogram`(ECR-050)
|
||||
|
||||
@@ -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)。
|
||||
@@ -0,0 +1,73 @@
|
||||
# Feature Spec: GrowthInsights · ReportTemplate 写面(Ops · ECR-048)
|
||||
|
||||
> Status: `Active`(**Implemented · ECR-048 Closed**)
|
||||
> Map: `§7` · Capability: `GrowthInsights` · BC: `Explore_Reports`
|
||||
> Auth: `docs/WAVE0/ECR-048_WRITE_AUTHORIZATION.md`(**仅 ECR-048**)
|
||||
> Predecessor: ECR-038 Closed(只读)· ECR-047 Closed(Funnel 写)
|
||||
> 关联:[ops-report-template.md](ops-report-template.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
运营可 **创建 / 更新 / 上下架** `ReportTemplate`(报告模板目录);写鉴权 + 审计;**无**新 C 端路由。
|
||||
|
||||
**Closed 后立即 STOP**,不自动 ECR-049。
|
||||
|
||||
## 2. In / Out
|
||||
|
||||
| In | Out |
|
||||
|----|-----|
|
||||
| Admin POST/PUT `/admin/growth/report-templates*` | Funnel 再写 |
|
||||
| 复用 `admin.growth.write` | SystemPrompt · Knowledge · Chunk |
|
||||
| 复用表 `report_templates` | soft-delete · 真支付 · UGC |
|
||||
| 审计 | 题干 · Crisis/Handoff |
|
||||
| | 自动 ECR-049 · 新 C 端路由 |
|
||||
|
||||
## 3. Domain
|
||||
|
||||
| 字段 | 规则 |
|
||||
|------|------|
|
||||
| `code` | 唯一;`^[a-z][a-z0-9_]{1,62}$`;`system=true` **不可改 code** |
|
||||
| `title` | 必填 · ≤128 |
|
||||
| `scene` | 必填;`^[a-z][a-z0-9_]{0,30}$` · ≤32 |
|
||||
| `active` | `false` = 下架 |
|
||||
| `system` | 种子行;禁止物理删除 |
|
||||
|
||||
## 4. API
|
||||
|
||||
| Method | Path | 权限 |
|
||||
|--------|------|------|
|
||||
| GET | `/admin/growth/report-templates` · `/{id}` | `admin.growth.read` |
|
||||
| POST | `/admin/growth/report-templates` | `admin.growth.write` |
|
||||
| PUT | `/admin/growth/report-templates/{id}` | `admin.growth.write` |
|
||||
|
||||
无 DELETE · 无新 C 端路径。
|
||||
|
||||
## 5. Permission / Migration
|
||||
|
||||
- Migration `000057_ops_report_template_write`:幂等确认 `super_admin` → `admin.growth.write`
|
||||
- 编码时占号(Max=`000056` → `000057`)
|
||||
|
||||
## 6. UI
|
||||
|
||||
admin-h5 报告模板页:列表 + 新建/编辑/上下架。
|
||||
|
||||
## 7. AC
|
||||
|
||||
| ID | Then |
|
||||
|----|------|
|
||||
| AC-F-01 | POST → list/get 可见 |
|
||||
| AC-F-02 | PUT active=false → list 仍可见且 active=false |
|
||||
| AC-F-03 | 重复 code → 409 |
|
||||
| AC-S-01/02 | 401;仅 growth.read → POST 403 |
|
||||
| AC-A-01 | 写产生审计 |
|
||||
| AC-O-01 | 无 soft-delete · 无 Funnel/Prompt 写路径 |
|
||||
|
||||
## 8. Closed 后
|
||||
|
||||
**立即 STOP**。ECR-049 须新 Candidate Review + Human 拍板。
|
||||
|
||||
## 9. Trace
|
||||
|
||||
ECR-048 · BD-2026-048 · CONTRACT_DIFF · ECR-048_WRITE_AUTHORIZATION
|
||||
@@ -1,6 +1,7 @@
|
||||
# Feature Spec: GrowthInsights · ReportTemplate(Ops · ECR-038)
|
||||
|
||||
> Status: `Active`(Loop continuous · **ECR-038 Closed**)
|
||||
> **写面:** [ops-report-template-write.md](ops-report-template-write.md)(**ECR-048 Closed**)
|
||||
> Parent: WAVE0-FROZEN · Predecessor: ECR-037 Closed
|
||||
> Capability: `GrowthInsights` · BC: `Explore_Reports`
|
||||
> 授权:`docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
|
||||
@@ -3,6 +3,7 @@ __pycache__/
|
||||
.venv/
|
||||
*.log
|
||||
.env
|
||||
deploy.env
|
||||
.DS_Store
|
||||
|
||||
# ESS — machine-local root pointer(仓库内用 scripts 包装器)
|
||||
|
||||
@@ -718,9 +718,55 @@ export const adminApi = {
|
||||
updated_at: string
|
||||
}>('PUT', `/explore/image-card-decks/${id}`, body),
|
||||
reportTemplates: () =>
|
||||
request<{ items: Array<Record<string, unknown>> }>('GET', '/growth/report-templates'),
|
||||
request<{
|
||||
items: Array<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
scene: string
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>
|
||||
}>('GET', '/growth/report-templates'),
|
||||
reportTemplate: (id: string) =>
|
||||
request<Record<string, unknown>>('GET', `/growth/report-templates/${id}`),
|
||||
request<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
scene: string
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>('GET', `/growth/report-templates/${id}`),
|
||||
createReportTemplate: (body: {
|
||||
code: string
|
||||
title: string
|
||||
scene: string
|
||||
active: boolean
|
||||
}) =>
|
||||
request<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
scene: string
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>('POST', '/growth/report-templates', body),
|
||||
updateReportTemplate: (
|
||||
id: string,
|
||||
body: { code: string; title: string; scene: string; active: boolean },
|
||||
) =>
|
||||
request<{
|
||||
id: string
|
||||
code: string
|
||||
title: string
|
||||
scene: string
|
||||
active: boolean
|
||||
system: boolean
|
||||
updated_at: string
|
||||
}>('PUT', `/growth/report-templates/${id}`, body),
|
||||
funnelDefinitions: () =>
|
||||
request<{
|
||||
items: Array<{
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<footer class="icp-footer">
|
||||
<a class="icp-link" :href="ICP_MIIT_URL" target="_blank" rel="noopener noreferrer">{{ ICP_RECORD }}</a>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ICP_MIIT_URL, ICP_RECORD } from '@yuxingu/utils'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icp-footer {
|
||||
text-align: center;
|
||||
padding: 1rem 0 0.5rem;
|
||||
}
|
||||
.icp-link {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--muted, #8a7f7b);
|
||||
text-decoration: none;
|
||||
}
|
||||
.icp-link:hover {
|
||||
color: var(--accent, #e54d42);
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterLink, RouterView, useRouter } from 'vue-router'
|
||||
import BrandLogo from '@/components/BrandLogo.vue'
|
||||
import IcpFooter from '@/components/IcpFooter.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
@@ -40,6 +41,7 @@ async function onLogout() {
|
||||
<RouterLink to="/image-card-decks">意象牌组</RouterLink>
|
||||
<RouterLink to="/scale-definitions">量表元数据</RouterLink>
|
||||
<RouterLink to="/funnel-definitions">漏斗定义</RouterLink>
|
||||
<RouterLink to="/report-templates">报告模板</RouterLink>
|
||||
<RouterLink to="/catalogs">目录仓</RouterLink>
|
||||
<RouterLink to="/orders">订单</RouterLink>
|
||||
<RouterLink v-if="auth.can('admin.membership.plans.read')" to="/pricing">定价</RouterLink>
|
||||
@@ -54,7 +56,10 @@ async function onLogout() {
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<RouterView />
|
||||
<div class="main-body">
|
||||
<RouterView />
|
||||
</div>
|
||||
<IcpFooter />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -90,7 +95,13 @@ nav a.router-link-active {
|
||||
}
|
||||
.foot { margin-top: auto; display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.role { font-size: 0.75rem; color: var(--muted); }
|
||||
.main { padding: 1.5rem 1.75rem; }
|
||||
.main {
|
||||
padding: 1.5rem 1.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.main-body { flex: 1; min-width: 0; }
|
||||
@media (max-width: 800px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.nav { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import BrandLogo from '@/components/BrandLogo.vue'
|
||||
import IcpFooter from '@/components/IcpFooter.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const auth = useAuthStore()
|
||||
@@ -46,11 +47,24 @@ async function onSubmit() {
|
||||
{{ loading ? '登录中…' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
<IcpFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap { min-height: 100vh; display: grid; place-items: center; padding: 1.5rem; }
|
||||
.wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.wrap :deep(.icp-footer) {
|
||||
margin-top: auto;
|
||||
width: 100%;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
.login { width: min(380px, 100%); text-align: center; }
|
||||
.login :deep(.field) { text-align: left; }
|
||||
h1 { margin: 0 0 0.25rem; font-size: 1.45rem; font-family: "Noto Serif SC", "Songti SC", serif; }
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { adminApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
type Row = Awaited<ReturnType<typeof adminApi.reportTemplates>>['items'][number]
|
||||
|
||||
const auth = useAuthStore()
|
||||
const canWrite = () => auth.can('admin.growth.write')
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref<Row[]>([])
|
||||
const selected = ref<Row | null>(null)
|
||||
const saving = ref(false)
|
||||
const formErr = ref('')
|
||||
const form = reactive({
|
||||
code: '',
|
||||
title: '',
|
||||
scene: 'portrait',
|
||||
active: true,
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await adminApi.reportTemplates()
|
||||
items.value = res.items || []
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.code = ''
|
||||
form.title = ''
|
||||
form.scene = 'portrait'
|
||||
form.active = true
|
||||
selected.value = null
|
||||
formErr.value = ''
|
||||
}
|
||||
|
||||
async function openRow(id: string) {
|
||||
formErr.value = ''
|
||||
try {
|
||||
const row = await adminApi.reportTemplate(id)
|
||||
selected.value = row
|
||||
form.code = row.code
|
||||
form.title = row.title
|
||||
form.scene = row.scene
|
||||
form.active = row.active
|
||||
} catch {
|
||||
selected.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function payload() {
|
||||
return {
|
||||
code: form.code.trim(),
|
||||
title: form.title.trim(),
|
||||
scene: form.scene.trim(),
|
||||
active: form.active,
|
||||
}
|
||||
}
|
||||
|
||||
async function createRow() {
|
||||
if (!canWrite()) return
|
||||
saving.value = true
|
||||
formErr.value = ''
|
||||
try {
|
||||
const row = await adminApi.createReportTemplate(payload())
|
||||
await load()
|
||||
await openRow(row.id)
|
||||
} catch (e) {
|
||||
formErr.value = e instanceof Error ? e.message : '创建失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRow() {
|
||||
if (!selected.value || !canWrite()) return
|
||||
saving.value = true
|
||||
formErr.value = ''
|
||||
try {
|
||||
const row = await adminApi.updateReportTemplate(selected.value.id, payload())
|
||||
selected.value = row
|
||||
await load()
|
||||
} catch (e) {
|
||||
formErr.value = e instanceof Error ? e.message : '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive() {
|
||||
if (!selected.value || !canWrite()) return
|
||||
form.active = !form.active
|
||||
await saveRow()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>报告模板</h1>
|
||||
<p class="muted">ReportTemplate 写面 · 下架仅 active=false · ECR-048</p>
|
||||
<p v-if="!canWrite()" class="err">需要 admin.growth.write 才可写</p>
|
||||
<p v-if="loading" class="muted">加载中…</p>
|
||||
<p v-else-if="error" class="err">{{ error }}</p>
|
||||
<div v-else class="layout">
|
||||
<div class="card">
|
||||
<h2>列表</h2>
|
||||
<button v-if="canWrite()" class="btn" type="button" @click="resetForm">新建</button>
|
||||
<p v-if="!items.length" class="muted">暂无</p>
|
||||
<table v-else>
|
||||
<thead>
|
||||
<tr><th>代码</th><th>标题</th><th>场景</th><th>状态</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="b in items" :key="b.id">
|
||||
<td><code>{{ b.code }}</code></td>
|
||||
<td>{{ b.title }}</td>
|
||||
<td>{{ b.scene }}</td>
|
||||
<td>{{ b.active ? '启用' : '停用' }}</td>
|
||||
<td><button class="btn" type="button" @click="openRow(b.id)">编辑</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>{{ selected ? '编辑' : '新建' }}</h2>
|
||||
<label>代码 <input v-model="form.code" :disabled="!!selected?.system || !canWrite()" /></label>
|
||||
<label>标题 <input v-model="form.title" :disabled="!canWrite()" /></label>
|
||||
<label>场景 <input v-model="form.scene" :disabled="!canWrite()" /></label>
|
||||
<label class="check"><input v-model="form.active" type="checkbox" :disabled="!canWrite()" /> 启用</label>
|
||||
<p v-if="formErr" class="err">{{ formErr }}</p>
|
||||
<div v-if="canWrite()" class="actions">
|
||||
<button v-if="!selected" class="btn primary" type="button" :disabled="saving" @click="createRow">
|
||||
创建
|
||||
</button>
|
||||
<template v-else>
|
||||
<button class="btn primary" type="button" :disabled="saving" @click="saveRow">保存</button>
|
||||
<button class="btn" type="button" :disabled="saving" @click="toggleActive">
|
||||
{{ form.active ? '下架' : '上架' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { margin: 0 0 0.35rem; font-size: 1.35rem; }
|
||||
h2 { margin: 0 0 0.6rem; font-size: 1.05rem; }
|
||||
.layout { display: grid; grid-template-columns: 1.2fr 1fr; gap: 1rem; margin-top: 1rem; }
|
||||
code { font-size: 0.8rem; }
|
||||
.card label { display: block; margin: 0.4rem 0; font-size: 0.9rem; }
|
||||
.card input { width: 100%; margin-top: 0.2rem; }
|
||||
.check { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
|
||||
.btn.primary { font-weight: 600; }
|
||||
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -54,6 +54,12 @@ const router = createRouter({
|
||||
component: () => import('@/pages/FunnelDefinitionPage.vue'),
|
||||
meta: { permission: 'admin.growth.read' },
|
||||
},
|
||||
{
|
||||
path: 'report-templates',
|
||||
name: 'report-templates',
|
||||
component: () => import('@/pages/ReportTemplatePage.vue'),
|
||||
meta: { permission: 'admin.growth.read' },
|
||||
},
|
||||
{ path: 'catalogs', name: 'catalogs', component: () => import('@/pages/CatalogHubPage.vue') },
|
||||
{ path: 'push', name: 'push', component: () => import('@/pages/PushJobsPage.vue') },
|
||||
{ path: 'admins', name: 'admins', component: () => import('@/pages/AdminsPage.vue'), meta: { superOnly: true } },
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -16,6 +16,8 @@ func (h *AdminHandler) registerReportTemplates(authed *gin.RouterGroup) {
|
||||
g := authed.Group("/growth")
|
||||
g.GET("/report-templates", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.ListReportTemplates)
|
||||
g.GET("/report-templates/:id", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthRead), h.GetReportTemplate)
|
||||
g.POST("/report-templates", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthWrite), h.CreateReportTemplate)
|
||||
g.PUT("/report-templates/:id", middleware.RequireAdminPermission(h.Svc, admin.PermGrowthWrite), h.UpdateReportTemplate)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListReportTemplates(c *gin.Context) {
|
||||
@@ -44,3 +46,66 @@ func (h *AdminHandler) GetReportTemplate(c *gin.Context) {
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateReportTemplate(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||
return
|
||||
}
|
||||
var body admin.ReportTemplateWriteBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40054, "invalid body")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.CreateReportTemplate(c.Request.Context(), adminID, body)
|
||||
if errors.Is(err, admin.ErrInvalidReportTemplate) {
|
||||
response.Fail(c, http.StatusBadRequest, 40055, "invalid report template")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrReportTemplateConflict) {
|
||||
response.Fail(c, http.StatusConflict, 40912, "report template code conflict")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50052, "create report template failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) UpdateReportTemplate(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid id")
|
||||
return
|
||||
}
|
||||
var body admin.ReportTemplateWriteBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40054, "invalid body")
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.UpdateReportTemplate(c.Request.Context(), adminID, id, body)
|
||||
if errors.Is(err, admin.ErrReportTemplateNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40420, "report-template not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrInvalidReportTemplate) {
|
||||
response.Fail(c, http.StatusBadRequest, 40055, "invalid report template")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrReportTemplateConflict) {
|
||||
response.Fail(c, http.StatusConflict, 40912, "report template code conflict")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50053, "update report template failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, row)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestGrowthReportTemplateWrite(t *testing.T) {
|
||||
r, pool := setupAPIPool(t)
|
||||
ctx := context.Background()
|
||||
tok := adminLogin(t, r, "admin", "change-me")
|
||||
|
||||
code := fmt.Sprintf("rt_w_%d", time.Now().UnixNano()%1_000_000)
|
||||
body := map[string]any{"code": code, "title": "测试报告模板", "scene": "portrait", "active": true}
|
||||
env, httpCode := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/growth/report-templates", body, tok)
|
||||
if httpCode != 200 || env.Code != 0 {
|
||||
t.Fatalf("create http=%d code=%d msg=%s", httpCode, env.Code, env.Message)
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Scene string `json:"scene"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &created)
|
||||
if created.ID == "" || created.Code != code || created.Scene != "portrait" || !created.Active {
|
||||
t.Fatalf("bad create %#v", created)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM report_templates WHERE id=$1`, created.ID)
|
||||
})
|
||||
|
||||
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/growth/report-templates", body, tok)
|
||||
if httpCode != http.StatusConflict {
|
||||
t.Fatalf("dup expected 409 got %d", httpCode)
|
||||
}
|
||||
|
||||
env, httpCode = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/growth/report-templates", nil, tok)
|
||||
if httpCode != 200 {
|
||||
t.Fatalf("list %d", httpCode)
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
Code string `json:"code"`
|
||||
Active bool `json:"active"`
|
||||
} `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &list)
|
||||
found := false
|
||||
for _, it := range list.Items {
|
||||
if it.Code == code && it.Active {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing active in list %#v", list.Items)
|
||||
}
|
||||
|
||||
body["active"] = false
|
||||
body["title"] = "测试报告模板下架"
|
||||
env, httpCode = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/growth/report-templates/"+created.ID, body, tok)
|
||||
if httpCode != 200 {
|
||||
t.Fatalf("update %d", httpCode)
|
||||
}
|
||||
var updated struct {
|
||||
Active bool `json:"active"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &updated)
|
||||
if updated.Active || updated.Title != "测试报告模板下架" {
|
||||
t.Fatalf("bad update %#v", updated)
|
||||
}
|
||||
|
||||
var n int
|
||||
_ = pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM admin_audit_logs
|
||||
WHERE action IN ('growth.report_template.create','growth.report_template.update') AND target_id=$1`,
|
||||
created.ID).Scan(&n)
|
||||
if n < 2 {
|
||||
t.Fatalf("audit %d", n)
|
||||
}
|
||||
|
||||
limitedRoleID := uuid.New()
|
||||
_, _ = pool.Exec(ctx, `INSERT INTO admin_roles(id, name, system) VALUES ($1,$2,false)`,
|
||||
limitedRoleID, "rt_ro_"+limitedRoleID.String()[:8])
|
||||
_, _ = pool.Exec(ctx, `INSERT INTO admin_role_permissions(role_id, code) VALUES ($1,'admin.growth.read')`, limitedRoleID)
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("ro-pass"), bcrypt.DefaultCost)
|
||||
roUser := fmt.Sprintf("rtro_%d", time.Now().UnixNano())
|
||||
_, _ = pool.Exec(ctx, `INSERT INTO admin_accounts(username, password_hash, role_id) VALUES ($1,$2,$3)`,
|
||||
roUser, string(hash), limitedRoleID)
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_accounts WHERE username=$1`, roUser)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM admin_roles WHERE id=$1`, limitedRoleID)
|
||||
})
|
||||
roTok := adminLogin(t, r, roUser, "ro-pass")
|
||||
_, httpCode = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/growth/report-templates", map[string]any{
|
||||
"code": "x_ro", "title": "no", "scene": "portrait", "active": true,
|
||||
}, roTok)
|
||||
if httpCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 got %d", httpCode)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// ReportTemplateWriteInput is create/update payload.
|
||||
type ReportTemplateWriteInput struct {
|
||||
Code string
|
||||
Title string
|
||||
Scene string
|
||||
Active bool
|
||||
}
|
||||
|
||||
// CreateReportTemplateWithAudit inserts and audits.
|
||||
func (r *AdminRepo) CreateReportTemplateWithAudit(
|
||||
ctx context.Context, adminID uuid.UUID, in ReportTemplateWriteInput, meta json.RawMessage,
|
||||
) (*ReportTemplateRow, error) {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var row ReportTemplateRow
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO report_templates(code, title, scene, active, system)
|
||||
VALUES ($1,$2,$3,$4,false)
|
||||
RETURNING id, code, title, scene, active, system, updated_at`,
|
||||
in.Code, in.Title, in.Scene, in.Active,
|
||||
).Scan(&row.ID, &row.Code, &row.Title, &row.Scene, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, mapReportTemplateWriteErr(err)
|
||||
}
|
||||
if meta == nil {
|
||||
meta = json.RawMessage(`{}`)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,'growth.report_template.create','report_template',$2,$3)`,
|
||||
adminID, row.ID.String(), meta,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// UpdateReportTemplateWithAudit updates and audits.
|
||||
func (r *AdminRepo) UpdateReportTemplateWithAudit(
|
||||
ctx context.Context, adminID, id uuid.UUID, in ReportTemplateWriteInput, meta json.RawMessage,
|
||||
) (*ReportTemplateRow, error) {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var system bool
|
||||
var oldCode string
|
||||
err = tx.QueryRow(ctx, `SELECT system, code FROM report_templates WHERE id=$1`, id).Scan(&system, &oldCode)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, pgx.ErrNoRows
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code := in.Code
|
||||
if system {
|
||||
code = oldCode
|
||||
}
|
||||
var row ReportTemplateRow
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE report_templates
|
||||
SET code=$2, title=$3, scene=$4, active=$5, updated_at=now()
|
||||
WHERE id=$1
|
||||
RETURNING id, code, title, scene, active, system, updated_at`,
|
||||
id, code, in.Title, in.Scene, in.Active,
|
||||
).Scan(&row.ID, &row.Code, &row.Title, &row.Scene, &row.Active, &row.System, &row.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, mapReportTemplateWriteErr(err)
|
||||
}
|
||||
if meta == nil {
|
||||
meta = json.RawMessage(`{}`)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,'growth.report_template.update','report_template',$2,$3)`,
|
||||
adminID, id.String(), meta,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func mapReportTemplateWriteErr(err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return errString("report template code conflict")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ReportTemplateCodeConflict reports unique violation.
|
||||
func ReportTemplateCodeConflict(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "report template code conflict")
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidReportTemplate = errors.New("invalid report template")
|
||||
ErrReportTemplateConflict = errors.New("report template code conflict")
|
||||
reportTemplateCodeRe = regexp.MustCompile(`^[a-z][a-z0-9_]{1,62}$`)
|
||||
reportTemplateSceneRe = regexp.MustCompile(`^[a-z][a-z0-9_]{0,30}$`)
|
||||
)
|
||||
|
||||
// ReportTemplateWriteBody is JSON for create/update.
|
||||
type ReportTemplateWriteBody struct {
|
||||
Code string `json:"code"`
|
||||
Title string `json:"title"`
|
||||
Scene string `json:"scene"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// CreateReportTemplate validates, inserts, audits.
|
||||
func (s *Service) CreateReportTemplate(ctx context.Context, adminID uuid.UUID, body ReportTemplateWriteBody) (*repository.ReportTemplateRow, error) {
|
||||
in, err := normalizeReportTemplateWrite(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"code": in.Code, "scene": in.Scene, "active": in.Active})
|
||||
row, err := s.Repo.CreateReportTemplateWithAudit(ctx, adminID, in, meta)
|
||||
if repository.ReportTemplateCodeConflict(err) {
|
||||
return nil, ErrReportTemplateConflict
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// UpdateReportTemplate validates, updates, audits.
|
||||
func (s *Service) UpdateReportTemplate(ctx context.Context, adminID, id uuid.UUID, body ReportTemplateWriteBody) (*repository.ReportTemplateRow, error) {
|
||||
in, err := normalizeReportTemplateWrite(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"code": in.Code, "scene": in.Scene, "active": in.Active})
|
||||
row, err := s.Repo.UpdateReportTemplateWithAudit(ctx, adminID, id, in, meta)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrReportTemplateNotFound
|
||||
}
|
||||
if repository.ReportTemplateCodeConflict(err) {
|
||||
return nil, ErrReportTemplateConflict
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
func normalizeReportTemplateWrite(body ReportTemplateWriteBody) (repository.ReportTemplateWriteInput, error) {
|
||||
code := strings.TrimSpace(body.Code)
|
||||
title := strings.TrimSpace(body.Title)
|
||||
scene := strings.TrimSpace(body.Scene)
|
||||
if !reportTemplateCodeRe.MatchString(code) {
|
||||
return repository.ReportTemplateWriteInput{}, ErrInvalidReportTemplate
|
||||
}
|
||||
if !reportTemplateSceneRe.MatchString(scene) {
|
||||
return repository.ReportTemplateWriteInput{}, ErrInvalidReportTemplate
|
||||
}
|
||||
if title == "" || utf8.RuneCountInString(title) > 128 {
|
||||
return repository.ReportTemplateWriteInput{}, ErrInvalidReportTemplate
|
||||
}
|
||||
return repository.ReportTemplateWriteInput{Code: code, Title: title, Scene: scene, Active: body.Active}, nil
|
||||
}
|
||||
@@ -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, ¬ice)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errors.New("测评不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qrows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, question_type, question_text, question_image, required, order_index
|
||||
FROM consult_questions WHERE test_id=$1 ORDER BY order_index, id`, studyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer qrows.Close()
|
||||
questions := []map[string]any{}
|
||||
for qrows.Next() {
|
||||
var qid, qt, req, ord int64
|
||||
var text, img string
|
||||
if err := qrows.Scan(&qid, &qt, &text, &img, &req, &ord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orows, err := s.Pool.Query(ctx, `SELECT id, option_text, order_index FROM consult_options WHERE question_id=$1 ORDER BY order_index, id`, qid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := []map[string]any{}
|
||||
for orows.Next() {
|
||||
var oid, oord int64
|
||||
var ot string
|
||||
if err := orows.Scan(&oid, &ot, &oord); err != nil {
|
||||
orows.Close()
|
||||
return nil, err
|
||||
}
|
||||
opts = append(opts, map[string]any{"id": oid, "optionText": ot, "orderIndex": oord})
|
||||
}
|
||||
orows.Close()
|
||||
questions = append(questions, map[string]any{
|
||||
"id": qid, "questionType": qt, "questionText": text, "questionImgae": img,
|
||||
"required": req, "orderIndex": ord, "questionOptionVOList": opts,
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"id": studyID, "testName": name, "subTitle": sub, "testPic": pic,
|
||||
"testIntroduction": intro, "totalNum": total, "testNotice": notice,
|
||||
"questionDetailVOList": questions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ChoiceIn struct {
|
||||
ID int64 `json:"id"`
|
||||
TotalTime int `json:"totalTime"`
|
||||
UserChoiceOptionVOList []struct {
|
||||
QuestionID int64 `json:"questionId"`
|
||||
OptionID int64 `json:"optionId"`
|
||||
} `json:"userChoiceOptionVOList"`
|
||||
}
|
||||
|
||||
func (s *Service) SaveChoice(ctx context.Context, userID uuid.UUID, in ChoiceIn) (int64, error) {
|
||||
if in.ID == 0 {
|
||||
return 0, errors.New("缺少测评")
|
||||
}
|
||||
score := 0
|
||||
for _, it := range in.UserChoiceOptionVOList {
|
||||
var sc int
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT option_score FROM consult_options WHERE id=$1`, it.OptionID).Scan(&sc)
|
||||
score += sc
|
||||
}
|
||||
var resultID int64
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id FROM consult_test_results
|
||||
WHERE test_id=$1 AND $2 BETWEEN min_score AND max_score
|
||||
ORDER BY id LIMIT 1`, in.ID, score).Scan(&resultID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = s.Pool.QueryRow(ctx, `SELECT id FROM consult_test_results WHERE test_id=$1 ORDER BY min_score LIMIT 1`, in.ID).Scan(&resultID)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, errors.New("暂无匹配结果")
|
||||
}
|
||||
raw, _ := json.Marshal(in.UserChoiceOptionVOList)
|
||||
var choiceID int64
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO consult_user_choices(user_id, test_id, result_id, total_time, choice_info)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING id`, userID, in.ID, resultID, in.TotalTime, raw).Scan(&choiceID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
_, _ = s.Pool.Exec(ctx, `UPDATE consult_tests SET total_num=total_num+1, actual_num=actual_num+1 WHERE id=$1`, in.ID)
|
||||
return resultID, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetResult(ctx context.Context, resultID int64) (map[string]any, error) {
|
||||
var testID int64
|
||||
var desc, analysis, plan, name, sub, pic string
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT r.test_id, r.result_desc, r.result_analysis, r.treat_plan, t.test_name, t.sub_title, t.test_pic
|
||||
FROM consult_test_results r JOIN consult_tests t ON t.id=r.test_id
|
||||
WHERE r.id=$1`, resultID).Scan(&testID, &desc, &analysis, &plan, &name, &sub, &pic)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errors.New("结果不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"id": resultID, "testId": testID, "testName": name, "subTitle": sub, "testPic": pic,
|
||||
"resultDesc": desc, "resultAnalysis": analysis, "treatPlan": plan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) MyTests(ctx context.Context, userID uuid.UUID, pageNo, pageSize int) (map[string]any, error) {
|
||||
if pageNo <= 0 {
|
||||
pageNo = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
var total int
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_user_choices WHERE user_id=$1`, userID).Scan(&total)
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT c.id, t.test_name, t.test_pic, t.sub_title, c.result_id, to_char(c.created_at,'YYYY-MM-DD HH24:MI')
|
||||
FROM consult_user_choices c JOIN consult_tests t ON t.id=c.test_id
|
||||
WHERE c.user_id=$1 ORDER BY c.id DESC OFFSET $2 LIMIT $3`, userID, (pageNo-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
list := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, rid int64
|
||||
var name, pic, sub, start string
|
||||
if err := rows.Scan(&id, &name, &pic, &sub, &rid, &start); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, map[string]any{"id": id, "testName": name, "testPic": pic, "subTitle": sub, "testResultId": rid, "startTime": start})
|
||||
}
|
||||
return map[string]any{"list": list, "total": total}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) Scopes(ctx context.Context) ([]map[string]any, error) {
|
||||
rows, err := s.Pool.Query(ctx, `SELECT code, name FROM consult_business_scopes ORDER BY code`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var code, name string
|
||||
if err := rows.Scan(&code, &name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, map[string]any{"code": code, "name": name})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) DoctorPage(ctx context.Context, scope string, isTop, pageNo, pageSize int) (map[string]any, error) {
|
||||
if pageNo <= 0 {
|
||||
pageNo = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
scope = strings.TrimSpace(scope)
|
||||
var total int
|
||||
_ = s.Pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM consult_doctors
|
||||
WHERE status=1 AND ($1='' OR business_scope LIKE '%'||$1||'%') AND ($2<0 OR is_top=$2)`, scope, isTop).Scan(&total)
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, name, avatar, business_scope, tags, consultation_method, introduction, price
|
||||
FROM consult_doctors
|
||||
WHERE status=1 AND ($1='' OR business_scope LIKE '%'||$1||'%') AND ($2<0 OR is_top=$2)
|
||||
ORDER BY is_top DESC, id OFFSET $3 LIMIT $4`, scope, isTop, (pageNo-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
list := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, price int64
|
||||
var name, avatar, bs, tags, method, intro string
|
||||
if err := rows.Scan(&id, &name, &avatar, &bs, &tags, &method, &intro, &price); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, map[string]any{
|
||||
"id": id, "name": name, "avatar": avatar, "businessScope": bs, "tags": tags,
|
||||
"consultationMethod": method, "introduction": intro, "price": price, "availableDate": "",
|
||||
})
|
||||
}
|
||||
return map[string]any{"list": list, "total": total}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) DoctorGet(ctx context.Context, id int64, userID uuid.UUID) (map[string]any, error) {
|
||||
var name, avatar, bs, cover, method, edu, intro, resume, notice, tags, exp, addr, addrD string
|
||||
var price, status, showN, top int64
|
||||
var workStart *time.Time
|
||||
var created time.Time
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT name, avatar, business_scope, cover_url, consultation_method, education, introduction, resume, notice, tags,
|
||||
work_experience, work_start_time, price, status, address, address_detail, show_service_num, is_top, created_at
|
||||
FROM consult_doctors WHERE id=$1`, id).Scan(
|
||||
&name, &avatar, &bs, &cover, &method, &edu, &intro, &resume, ¬ice, &tags,
|
||||
&exp, &workStart, &price, &status, &addr, &addrD, &showN, &top, &created)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errors.New("咨询师不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
focus := false
|
||||
if userID != uuid.Nil {
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM consult_focus WHERE user_id=$1 AND doctor_id=$2 AND status=1)`, userID, id).Scan(&focus)
|
||||
}
|
||||
ws := ""
|
||||
if workStart != nil {
|
||||
ws = workStart.Format("2006-01-02")
|
||||
}
|
||||
if strings.TrimSpace(cover) == "" {
|
||||
cover = avatar
|
||||
}
|
||||
return map[string]any{
|
||||
"id": id, "userId": 0, "name": name, "avatar": avatar, "businessScope": bs, "coverUrl": cover,
|
||||
"consultationMethod": method, "education": edu, "introduction": intro, "resume": resume,
|
||||
"notice": notice, "tags": tags, "workExperience": exp, "workStartTime": ws, "price": price,
|
||||
"createTime": created.Format(time.RFC3339), "focus": focus, "address": addr, "addressDetail": addrD,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetFocus(ctx context.Context, userID uuid.UUID, doctorID int64, on bool) error {
|
||||
st := 0
|
||||
if on {
|
||||
st = 1
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO consult_focus(user_id, doctor_id, status, read_status)
|
||||
VALUES ($1,$2,$3,0)
|
||||
ON CONFLICT (user_id, doctor_id) DO UPDATE SET status=$3, read_status=0`, userID, doctorID, st)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) ShowInfo(ctx context.Context, doctorID int64, userID uuid.UUID) (map[string]any, error) {
|
||||
d, err := s.DoctorGet(ctx, doctorID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{
|
||||
"id": d["id"], "name": d["name"], "avatar": d["avatar"],
|
||||
"consultationMethod": d["consultationMethod"], "price": d["price"], "address": d["address"],
|
||||
}
|
||||
if userID != uuid.Nil {
|
||||
var name, phone string
|
||||
var sex int
|
||||
var bday *time.Time
|
||||
var em string
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT appointment_name, birthday, phone, sex, emergency_contact_info
|
||||
FROM consult_orders WHERE user_id=$1 AND user_deleted=0 ORDER BY id DESC LIMIT 1`, userID).
|
||||
Scan(&name, &bday, &phone, &sex, &em)
|
||||
if err == nil {
|
||||
bs := ""
|
||||
if bday != nil {
|
||||
bs = bday.Format("2006-01-02")
|
||||
}
|
||||
emPhone, emType := "", 1
|
||||
var obj map[string]any
|
||||
if json.Unmarshal([]byte(em), &obj) == nil {
|
||||
if v, ok := obj["phone"].(string); ok {
|
||||
emPhone = v
|
||||
}
|
||||
switch v := obj["type"].(type) {
|
||||
case float64:
|
||||
emType = int(v)
|
||||
}
|
||||
}
|
||||
out["appointmentInfoAppRespVO"] = map[string]any{
|
||||
"appointmentName": name, "birthday": bs, "phone": phone, "sex": sex,
|
||||
"emergencyContactPhone": emPhone, "emergencyContactType": emType,
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) RemainList(ctx context.Context, doctorID int64) ([]map[string]any, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT d.schedule_date, d.id,
|
||||
(SELECT COUNT(*) FROM consult_slots s WHERE s.schedule_id=d.id AND s.status=0)
|
||||
FROM consult_schedule_days d
|
||||
WHERE d.doctor_id=$1 AND d.schedule_date >= CURRENT_DATE AND d.schedule_date < CURRENT_DATE+30
|
||||
ORDER BY d.schedule_date`, doctorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var day time.Time
|
||||
var sid, remain int64
|
||||
if err := rows.Scan(&day, &sid, &remain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, map[string]any{"scheduleDate": day.Format("2006-01-02"), "remainNum": remain, "scheduleDateId": sid})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) DateDetail(ctx context.Context, doctorID int64) ([]map[string]any, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT d.id, d.schedule_date FROM consult_schedule_days d
|
||||
WHERE d.doctor_id=$1 AND d.schedule_date >= CURRENT_DATE AND d.schedule_date < CURRENT_DATE+13
|
||||
ORDER BY d.schedule_date`, doctorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var sid int64
|
||||
var day time.Time
|
||||
if err := rows.Scan(&sid, &day); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
srows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, start_time, end_time, consultation_method FROM consult_slots
|
||||
WHERE schedule_id=$1 AND status=0 ORDER BY start_time`, sid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slots := []map[string]any{}
|
||||
for srows.Next() {
|
||||
var id int64
|
||||
var st, et time.Time
|
||||
var method string
|
||||
if err := srows.Scan(&id, &st, &et, &method); err != nil {
|
||||
srows.Close()
|
||||
return nil, err
|
||||
}
|
||||
slots = append(slots, map[string]any{
|
||||
"slotId": id, "startTime": st.Format("15:04:05"), "endTime": et.Format("15:04:05"),
|
||||
"consultationMethod": method,
|
||||
})
|
||||
}
|
||||
srows.Close()
|
||||
out = append(out, map[string]any{
|
||||
"scheduleId": sid, "scheduleDate": day.Format("2006-01-02"),
|
||||
"weekStr": weekdayCN(day), "slotVOList": slots,
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type OrderIn struct {
|
||||
SlotID int64 `json:"slotId"`
|
||||
AppointmentName string `json:"appointmentName"`
|
||||
Birthday string `json:"birthday"`
|
||||
Phone string `json:"phone"`
|
||||
Sex int `json:"sex"`
|
||||
EmergencyContactInfo string `json:"emergencyContactInfo"`
|
||||
TotalAmount int `json:"totalAmount"`
|
||||
ConsultationMethod string `json:"consultationMethod"`
|
||||
}
|
||||
|
||||
func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in OrderIn) (wechatpay.JSAPIParams, error) {
|
||||
var doctorID int64
|
||||
var day time.Time
|
||||
var st, et time.Time
|
||||
var status int
|
||||
var price int
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT s.doctor_id, d.schedule_date, s.start_time, s.end_time, s.status, doc.price
|
||||
FROM consult_slots s
|
||||
JOIN consult_schedule_days d ON d.id=s.schedule_id
|
||||
JOIN consult_doctors doc ON doc.id=s.doctor_id
|
||||
WHERE s.id=$1`, in.SlotID).Scan(&doctorID, &day, &st, &et, &status, &price)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return wechatpay.JSAPIParams{}, errors.New("时段不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return wechatpay.JSAPIParams{}, err
|
||||
}
|
||||
if status != 0 {
|
||||
return wechatpay.JSAPIParams{}, errors.New("该时段已被预约")
|
||||
}
|
||||
if in.TotalAmount > 0 && in.TotalAmount != price {
|
||||
return wechatpay.JSAPIParams{}, errors.New("价格已变化,请刷新")
|
||||
}
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return wechatpay.JSAPIParams{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
tag, err := tx.Exec(ctx, `UPDATE consult_slots SET status=1 WHERE id=$1 AND status=0`, in.SlotID)
|
||||
if err != nil || tag.RowsAffected() == 0 {
|
||||
return wechatpay.JSAPIParams{}, errors.New("该时段已被预约")
|
||||
}
|
||||
sn := fmt.Sprintf("YG%s%06d", time.Now().Format("20060102150405"), in.SlotID%1000000)
|
||||
method := in.ConsultationMethod
|
||||
if method == "" {
|
||||
method = "online"
|
||||
}
|
||||
var bday *time.Time
|
||||
if in.Birthday != "" {
|
||||
if t, e := time.Parse("2006-01-02", in.Birthday); e == nil {
|
||||
bday = &t
|
||||
}
|
||||
}
|
||||
valid := time.Now().Add(15 * time.Minute)
|
||||
var oid int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO consult_orders(order_sn, slot_id, doctor_id, user_id, appointment_date, start_time, end_time, status,
|
||||
appointment_name, birthday, phone, sex, emergency_contact_info, total_amount, valid_time, consultation_method)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,0,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING id`,
|
||||
sn, in.SlotID, doctorID, userID, day, st, et, in.AppointmentName, bday, in.Phone, in.Sex,
|
||||
in.EmergencyContactInfo, price, valid, method).Scan(&oid)
|
||||
if err != nil {
|
||||
return wechatpay.JSAPIParams{}, err
|
||||
}
|
||||
openid := ""
|
||||
_ = tx.QueryRow(ctx, `SELECT COALESCE(wx_openid,'') FROM users WHERE id=$1`, userID).Scan(&openid)
|
||||
pay, err := wechatpay.UnifiedOrder(s.Pay, openid, sn, price)
|
||||
if err != nil {
|
||||
return wechatpay.JSAPIParams{}, err
|
||||
}
|
||||
raw, _ := json.Marshal(pay)
|
||||
_, _ = tx.Exec(ctx, `UPDATE consult_orders SET pay_param=$2 WHERE id=$1`, oid, raw)
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return wechatpay.JSAPIParams{}, err
|
||||
}
|
||||
return pay, nil
|
||||
}
|
||||
|
||||
func (s *Service) PayParam(ctx context.Context, userID uuid.UUID, orderSn string) (wechatpay.JSAPIParams, error) {
|
||||
var raw []byte
|
||||
var owner uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `SELECT user_id, pay_param FROM consult_orders WHERE order_sn=$1 AND user_deleted=0`, orderSn).
|
||||
Scan(&owner, &raw)
|
||||
if err != nil {
|
||||
return wechatpay.JSAPIParams{}, errors.New("订单不存在")
|
||||
}
|
||||
if owner != userID {
|
||||
return wechatpay.JSAPIParams{}, errors.New("无权查看")
|
||||
}
|
||||
var p wechatpay.JSAPIParams
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
}
|
||||
if p.PaySign == "" {
|
||||
p = wechatpay.MockJSAPI()
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Service) OrderList(ctx context.Context, userID uuid.UUID, pageNo, pageSize int) (map[string]any, error) {
|
||||
if pageNo <= 0 {
|
||||
pageNo = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
var total int
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_orders WHERE user_id=$1 AND user_deleted=0`, userID).Scan(&total)
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT o.id, o.doctor_id, o.order_sn, d.name, d.avatar, o.consultation_method, o.status, o.total_amount,
|
||||
to_char(o.start_time,'HH24:MI:SS'), to_char(o.end_time,'HH24:MI:SS'), o.valid_time, o.created_at, o.cancel_time, o.cancel_flag
|
||||
FROM consult_orders o JOIN consult_doctors d ON d.id=o.doctor_id
|
||||
WHERE o.user_id=$1 AND o.user_deleted=0
|
||||
ORDER BY o.id DESC OFFSET $2 LIMIT $3`, userID, (pageNo-1)*pageSize, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
list := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, did, status, amount, cancelFlag int64
|
||||
var sn, name, avatar, method, st, et string
|
||||
var valid, created *time.Time
|
||||
var cancel *time.Time
|
||||
if err := rows.Scan(&id, &did, &sn, &name, &avatar, &method, &status, &amount, &st, &et, &valid, &created, &cancel, &cancelFlag); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := map[string]any{
|
||||
"id": id, "doctorId": did, "orderSn": sn, "name": name, "avatar": avatar,
|
||||
"consultationMethod": method, "status": status, "totalAmount": amount,
|
||||
"startTime": st, "endTime": et, "cancelFlag": cancelFlag,
|
||||
}
|
||||
if valid != nil {
|
||||
item["validTime"] = valid.Format(time.RFC3339)
|
||||
}
|
||||
if created != nil {
|
||||
item["createTime"] = created.Format(time.RFC3339)
|
||||
}
|
||||
if cancel != nil {
|
||||
item["cancelTime"] = cancel.Format(time.RFC3339)
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
return map[string]any{"list": list, "total": total}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) OrderDetail(ctx context.Context, userID uuid.UUID, orderID int64) (map[string]any, error) {
|
||||
var did int64
|
||||
var sn, aname, phone, method, em, addrD string
|
||||
var status, sex, amount, cancelFlag int64
|
||||
var bday *time.Time
|
||||
var st, et time.Time
|
||||
var day time.Time
|
||||
var name, avatar string
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT o.doctor_id, o.order_sn, o.appointment_name, o.birthday, o.phone, o.sex, o.emergency_contact_info,
|
||||
o.status, o.total_amount, o.consultation_method, o.cancel_flag, o.appointment_date, o.start_time, o.end_time,
|
||||
d.name, d.avatar, d.address_detail
|
||||
FROM consult_orders o JOIN consult_doctors d ON d.id=o.doctor_id
|
||||
WHERE o.id=$1 AND o.user_id=$2 AND o.user_deleted=0`, orderID, userID).
|
||||
Scan(&did, &sn, &aname, &bday, &phone, &sex, &em, &status, &amount, &method, &cancelFlag, &day, &st, &et, &name, &avatar, &addrD)
|
||||
if err != nil {
|
||||
return nil, errors.New("订单不存在")
|
||||
}
|
||||
bs := ""
|
||||
if bday != nil {
|
||||
bs = bday.Format("2006-01-02")
|
||||
}
|
||||
return map[string]any{
|
||||
"id": orderID, "doctorId": did, "orderSn": sn, "name": name, "avatar": avatar,
|
||||
"appointmentName": aname, "birthday": bs, "phone": phone, "sex": sex,
|
||||
"emergencyContactInfo": em, "status": status, "totalAmount": amount,
|
||||
"consultationMethod": method, "cancelFlag": cancelFlag, "addressDetail": addrD,
|
||||
"appointmentDate": day.Format("2006-01-02"),
|
||||
"startTime": st.Format("15:04:05"), "endTime": et.Format("15:04:05"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CancelOrder(ctx context.Context, userID uuid.UUID, orderID int64) error {
|
||||
var slotID int64
|
||||
var status int
|
||||
err := s.Pool.QueryRow(ctx, `SELECT slot_id, status FROM consult_orders WHERE id=$1 AND user_id=$2 AND user_deleted=0`, orderID, userID).
|
||||
Scan(&slotID, &status)
|
||||
if err != nil {
|
||||
return errors.New("订单不存在")
|
||||
}
|
||||
if status != 0 {
|
||||
return errors.New("当前状态不可取消")
|
||||
}
|
||||
_, _ = s.Pool.Exec(ctx, `UPDATE consult_slots SET status=0 WHERE id=$1`, slotID)
|
||||
_, err = s.Pool.Exec(ctx, `UPDATE consult_orders SET status=9, cancel_time=now(), cancel_flag=2 WHERE id=$1`, orderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) DeleteOrder(ctx context.Context, userID uuid.UUID, orderID int64) error {
|
||||
tag, err := s.Pool.Exec(ctx, `UPDATE consult_orders SET user_deleted=1 WHERE id=$1 AND user_id=$2`, orderID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errors.New("订单不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) MarkPaid(ctx context.Context, orderSn string) error {
|
||||
_, err := s.Pool.Exec(ctx, `UPDATE consult_orders SET status=1, pay_time=now() WHERE order_sn=$1 AND status=0`, orderSn)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) DemoPay(ctx context.Context, userID uuid.UUID) (wechatpay.JSAPIParams, error) {
|
||||
openid := ""
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COALESCE(wx_openid,'') FROM users WHERE id=$1`, userID).Scan(&openid)
|
||||
return wechatpay.UnifiedOrder(s.Pay, openid, fmt.Sprintf("DEMO%d", time.Now().Unix()), 1)
|
||||
}
|
||||
|
||||
func (s *Service) SelfInfo(ctx context.Context, userID uuid.UUID) (map[string]any, error) {
|
||||
var nick, avatar, phone, status string
|
||||
var created time.Time
|
||||
err := s.Pool.QueryRow(ctx, `SELECT COALESCE(nickname,''), COALESCE(avatar_url,''), COALESCE(phone,''), status, created_at FROM users WHERE id=$1`, userID).
|
||||
Scan(&nick, &avatar, &phone, &status, &created)
|
||||
if err != nil {
|
||||
return nil, errors.New("请先登录")
|
||||
}
|
||||
var stay, idCard, realName string
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COALESCE(stay_period,''), COALESCE(id_card,''), COALESCE(real_name,'') FROM consult_user_ext WHERE user_id=$1`, userID).
|
||||
Scan(&stay, &idCard, &realName)
|
||||
st := 1
|
||||
if status != "active" {
|
||||
st = 0
|
||||
}
|
||||
return map[string]any{
|
||||
"id": userID.String(), "status": st, "idCard": idCard, "realName": realName,
|
||||
"createTime": created.Format(time.RFC3339), "nickName": nick, "avatarUrl": avatar, "stayPeriod": stay, "phone": phone,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateSelf(ctx context.Context, userID uuid.UUID, nick, avatar, stay string) error {
|
||||
if nick != "" {
|
||||
_, _ = s.Pool.Exec(ctx, `UPDATE users SET nickname=$2, updated_at=now() WHERE id=$1`, userID, nick)
|
||||
}
|
||||
if avatar != "" {
|
||||
_, _ = s.Pool.Exec(ctx, `UPDATE users SET avatar_url=$2, updated_at=now() WHERE id=$1`, userID, avatar)
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO consult_user_ext(user_id, stay_period) VALUES ($1,$2)
|
||||
ON CONFLICT (user_id) DO UPDATE SET stay_period=COALESCE(NULLIF($2,''), consult_user_ext.stay_period), updated_at=now()`,
|
||||
userID, stay)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) NotRead(ctx context.Context, userID uuid.UUID) (map[string]any, error) {
|
||||
var focus, order int
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_focus WHERE user_id=$1 AND status=1 AND read_status=0`, userID).Scan(&focus)
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_orders WHERE user_id=$1 AND user_deleted=0 AND user_read=0`, userID).Scan(&order)
|
||||
return map[string]any{"focusNum": focus, "orderNum": order}, nil
|
||||
}
|
||||
|
||||
func (s *Service) FocusToRead(ctx context.Context, userID uuid.UUID) error {
|
||||
_, err := s.Pool.Exec(ctx, `UPDATE consult_focus SET read_status=1 WHERE user_id=$1`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) OrderToRead(ctx context.Context, userID uuid.UUID) error {
|
||||
_, err := s.Pool.Exec(ctx, `UPDATE consult_orders SET user_read=1 WHERE user_id=$1`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) FocusAll(ctx context.Context, userID uuid.UUID) ([]map[string]any, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT d.id, d.name, d.avatar, d.show_service_num, d.introduction, d.price, d.work_start_time
|
||||
FROM consult_focus f JOIN consult_doctors d ON d.id=f.doctor_id
|
||||
WHERE f.user_id=$1 AND f.status=1 ORDER BY f.id DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, show, price int64
|
||||
var name, avatar, intro string
|
||||
var ws *time.Time
|
||||
if err := rows.Scan(&id, &name, &avatar, &show, &intro, &price, &ws); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workNum := 0
|
||||
if ws != nil {
|
||||
workNum = time.Now().Year() - ws.Year()
|
||||
if workNum < 0 {
|
||||
workNum = 0
|
||||
}
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"id": id, "name": name, "avatar": avatar, "workNum": workNum,
|
||||
"showServiceNum": show, "introduction": intro, "price": price,
|
||||
"workStartTime": timeOrEmpty(ws),
|
||||
})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) Feedback(ctx context.Context, userID uuid.UUID, text string) error {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return errors.New("请填写内容")
|
||||
}
|
||||
phone := ""
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COALESCE(phone,'') FROM users WHERE id=$1`, userID).Scan(&phone)
|
||||
_, err := s.Pool.Exec(ctx, `INSERT INTO consult_feedback(user_id, content_text, contact) VALUES ($1,$2,$3)`, userID, text, phone)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) FeedbackFlag(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
var n int
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM consult_feedback WHERE user_id=$1 AND created_at > now()-interval '5 minutes'`, userID).Scan(&n)
|
||||
return n == 0, nil
|
||||
}
|
||||
|
||||
func weekdayCN(t time.Time) string {
|
||||
names := []string{"星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"}
|
||||
return names[int(t.Weekday())]
|
||||
}
|
||||
|
||||
func timeOrEmpty(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:]))
|
||||
}
|
||||
@@ -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,2 @@
|
||||
-- ECR-048 rollback marker (permission shared with ECR-047 — do not revoke growth.write)
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- ECR-048 GrowthInsights ReportTemplate write (reuse admin.growth.write)
|
||||
|
||||
INSERT INTO admin_role_permissions(role_id, code)
|
||||
SELECT r.id, 'admin.growth.write'
|
||||
FROM admin_roles r
|
||||
WHERE r.name = 'super_admin'
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -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);
|
||||
@@ -4,6 +4,33 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no,viewport-fit=cover" />
|
||||
<title>愈心谷</title>
|
||||
<script>
|
||||
;(function () {
|
||||
var q = location.search.slice(1).split('&')
|
||||
var p = {}
|
||||
for (var i = 0; i < q.length; i++) {
|
||||
var kv = q[i].split('=')
|
||||
if (kv[0]) p[kv[0]] = kv[1] || ''
|
||||
}
|
||||
if (p.mp === '1') {
|
||||
document.documentElement.classList.add('mp-embed')
|
||||
document.title = '\u200b'
|
||||
try { sessionStorage.setItem('yxg_mp_embed', '1') } catch (e) {}
|
||||
}
|
||||
if (p.nh === '1') {
|
||||
document.documentElement.classList.add('mp-native-header')
|
||||
document.title = '\u200b'
|
||||
try { sessionStorage.setItem('yxg_mp_native_header', '1') } catch (e) {}
|
||||
var top = 4
|
||||
if (p.sbh && /^\d+$/.test(p.sbh)) {
|
||||
top = parseInt(p.sbh, 10)
|
||||
try { sessionStorage.setItem('yxg_sbh', p.sbh) } catch (e) {}
|
||||
}
|
||||
document.documentElement.style.setProperty('--yxg-safe-top', '0px')
|
||||
document.documentElement.style.setProperty('--yxg-page-top', top + 'px')
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 357 KiB |
@@ -1,12 +1,34 @@
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<AppHeader />
|
||||
<router-view />
|
||||
<TabBar />
|
||||
<div class="app-shell" :class="shellClass">
|
||||
<div class="app-body">
|
||||
<AppHeader v-if="showFullHeader" />
|
||||
<AppHeader v-else-if="showMpBackBar" back-only />
|
||||
<router-view />
|
||||
</div>
|
||||
<IcpFooter class="app-footer" />
|
||||
<TabBar v-if="!mpEmbed" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppHeader from './components/AppHeader.vue'
|
||||
import IcpFooter from './components/IcpFooter.vue'
|
||||
import TabBar from './components/TabBar.vue'
|
||||
import { isMpEmbed, isMpNativeHeader } from './lib/mpEmbed'
|
||||
|
||||
const route = useRoute()
|
||||
const mpEmbed = isMpEmbed()
|
||||
const mpNativeHeader = isMpNativeHeader()
|
||||
const showFullHeader = computed(() => !mpNativeHeader)
|
||||
const showMpBackBar = computed(() => {
|
||||
if (!mpEmbed || !mpNativeHeader) return false
|
||||
const p = route.path
|
||||
return p !== '/' && p !== '/mine'
|
||||
})
|
||||
const shellClass = computed(() => ({
|
||||
'mp-embed': mpEmbed,
|
||||
'mp-native-header': mpNativeHeader,
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<header
|
||||
class="app-header"
|
||||
:class="{ 'is-embed': mpEmbed, 'back-only': backOnly }"
|
||||
>
|
||||
<div class="side left">
|
||||
<BackButton v-if="showBack" />
|
||||
</div>
|
||||
<router-link to="/" class="logo-link" aria-label="愈心谷首页">
|
||||
<router-link
|
||||
v-if="!backOnly"
|
||||
to="/"
|
||||
class="logo-link"
|
||||
aria-label="愈心谷首页"
|
||||
>
|
||||
<BrandLogo size="header" class="logo" />
|
||||
</router-link>
|
||||
<div class="side right" aria-hidden="true" />
|
||||
<div v-if="!backOnly" class="side right" aria-hidden="true" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -15,8 +23,13 @@ import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import BackButton from './BackButton.vue'
|
||||
import BrandLogo from './BrandLogo.vue'
|
||||
import { isMpEmbed } from '../lib/mpEmbed'
|
||||
|
||||
const props = defineProps<{ backOnly?: boolean }>()
|
||||
|
||||
const route = useRoute()
|
||||
const mpEmbed = isMpEmbed()
|
||||
const backOnly = computed(() => props.backOnly === true)
|
||||
|
||||
/** 首页 / 我的 为 Tab 根页不显示返回;探索可从首页进入,显示返回 */
|
||||
const showBack = computed(() => {
|
||||
@@ -36,10 +49,21 @@ const showBack = computed(() => {
|
||||
grid-template-columns: 44px 1fr 44px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 12px 4px;
|
||||
padding: calc(8px + var(--yxg-safe-top)) 12px 4px;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
.app-header.is-embed {
|
||||
position: relative;
|
||||
padding: calc(6px + var(--yxg-safe-top)) 12px 8px;
|
||||
background: transparent;
|
||||
}
|
||||
.app-header.back-only {
|
||||
display: block;
|
||||
grid-template-columns: 44px;
|
||||
padding: calc(4px + var(--yxg-page-top, 0px)) 12px 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<img
|
||||
class="brand-logo"
|
||||
:class="size"
|
||||
src="/logo.png?v=3"
|
||||
:src="logoSrc"
|
||||
alt="愈心谷"
|
||||
decoding="async"
|
||||
/>
|
||||
@@ -16,6 +16,8 @@ withDefaults(
|
||||
}>(),
|
||||
{ size: 'header' },
|
||||
)
|
||||
|
||||
const logoSrc = `${import.meta.env.BASE_URL}logo.png?v=3`
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<footer class="icp-footer" :class="{ 'mp-embed': mpEmbed }">
|
||||
<a class="icp-link" :href="ICP_MIIT_URL" target="_blank" rel="noopener noreferrer">{{ ICP_RECORD }}</a>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ICP_MIIT_URL, ICP_RECORD } from '@yuxingu/utils'
|
||||
import { isMpEmbed } from '../lib/mpEmbed'
|
||||
|
||||
const mpEmbed = isMpEmbed()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icp-footer {
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
padding: 20px 12px 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.icp-footer.mp-embed {
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
.icp-link {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: #9a8f8b;
|
||||
text-decoration: none;
|
||||
}
|
||||
.icp-link:active {
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
@@ -35,19 +35,15 @@ withDefaults(
|
||||
.ps {
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
padding-top: 48px;
|
||||
padding-top: var(--yxg-page-top);
|
||||
padding-bottom: var(--spacing-xs);
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(110% 70% at 85% -8%, rgba(255, 190, 170, 0.65) 0%, transparent 52%),
|
||||
radial-gradient(90% 55% at 8% 6%, rgba(255, 220, 200, 0.75) 0%, transparent 48%),
|
||||
radial-gradient(60% 40% at 50% 0%, rgba(255, 255, 255, 0.55) 0%, transparent 60%),
|
||||
linear-gradient(180deg, var(--color-bg-start) 0%, #ffe4d8 28%, #fff4ef 52%, var(--color-bg-sheet) 100%);
|
||||
background: var(--yxg-page-wash);
|
||||
}
|
||||
.ps-flat {
|
||||
background: transparent;
|
||||
min-height: 0;
|
||||
padding-top: 48px;
|
||||
padding-top: var(--yxg-page-top);
|
||||
}
|
||||
.atm {
|
||||
position: absolute;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { applyMpEmbedDom, initMpEmbed, isMpEmbed, isMpNativeHeader } from './mpEmbed'
|
||||
|
||||
describe('mpEmbed', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
document.documentElement.className = ''
|
||||
document.documentElement.style.removeProperty('--yxg-safe-top')
|
||||
document.documentElement.style.removeProperty('--yxg-page-top')
|
||||
window.history.replaceState({}, '', '/psy/')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('persists embed flag from ?mp=1', () => {
|
||||
window.history.replaceState({}, '', '/psy/?mp=1')
|
||||
initMpEmbed()
|
||||
expect(isMpEmbed()).toBe(true)
|
||||
})
|
||||
|
||||
it('persists status bar height from ?sbh=', () => {
|
||||
window.history.replaceState({}, '', '/psy/?mp=1&sbh=47')
|
||||
initMpEmbed()
|
||||
expect(document.documentElement.style.getPropertyValue('--yxg-safe-top')).toBe('47px')
|
||||
expect(document.documentElement.style.getPropertyValue('--yxg-page-top')).toBe('calc(47px + 50px)')
|
||||
expect(sessionStorage.getItem('yxg_sbh')).toBe('47')
|
||||
window.history.replaceState({}, '', '/psy/?mp=1')
|
||||
applyMpEmbedDom()
|
||||
expect(document.documentElement.style.getPropertyValue('--yxg-safe-top')).toBe('47px')
|
||||
})
|
||||
|
||||
it('uses native mini-program header mode with ?nh=1', () => {
|
||||
window.history.replaceState({}, '', '/psy/?mp=1&nh=1')
|
||||
initMpEmbed()
|
||||
expect(isMpNativeHeader()).toBe(true)
|
||||
expect(document.documentElement.classList.contains('mp-native-header')).toBe(true)
|
||||
expect(document.documentElement.style.getPropertyValue('--yxg-safe-top')).toBe('0px')
|
||||
expect(document.documentElement.style.getPropertyValue('--yxg-page-top')).toBe('4px')
|
||||
})
|
||||
|
||||
it('applies sbh in native header mode with ?nh=1&sbh=', () => {
|
||||
window.history.replaceState({}, '', '/psy/?mp=1&nh=1&sbh=12')
|
||||
initMpEmbed()
|
||||
expect(isMpNativeHeader()).toBe(true)
|
||||
expect(sessionStorage.getItem('yxg_sbh')).toBe('12')
|
||||
expect(document.documentElement.style.getPropertyValue('--yxg-page-top')).toBe('12px')
|
||||
window.history.replaceState({}, '', '/psy/?mp=1&nh=1')
|
||||
applyMpEmbedDom()
|
||||
expect(document.documentElement.style.getPropertyValue('--yxg-page-top')).toBe('12px')
|
||||
})
|
||||
|
||||
it('returns false without mp query', () => {
|
||||
initMpEmbed()
|
||||
expect(isMpEmbed()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
const MP_EMBED_KEY = 'yxg_mp_embed'
|
||||
const MP_NATIVE_HEADER_KEY = 'yxg_mp_native_header'
|
||||
const MP_QUERY = 'mp'
|
||||
const NH_QUERY = 'nh'
|
||||
const SBH_QUERY = 'sbh'
|
||||
const SBH_KEY = 'yxg_sbh'
|
||||
const HEADER_H_PX = 50
|
||||
const MP_CONTENT_TOP_PX = 4
|
||||
|
||||
/** 小程序顶栏在 web-view 外,H5 内容区仅留极小顶距 */
|
||||
function applyNativeHeaderInset(topPx: number): void {
|
||||
if (typeof document === 'undefined') return
|
||||
const root = document.documentElement
|
||||
root.style.setProperty('--yxg-safe-top', '0px')
|
||||
root.style.setProperty('--yxg-page-top', `${topPx}px`)
|
||||
}
|
||||
|
||||
function applyNativeHeaderMode(): void {
|
||||
applyNativeHeaderInset(MP_CONTENT_TOP_PX)
|
||||
}
|
||||
|
||||
function applySafeTopInset(px: number): void {
|
||||
if (typeof document === 'undefined' || px <= 0) return
|
||||
const root = document.documentElement
|
||||
const safe = `${px}px`
|
||||
root.style.setProperty('--yxg-safe-top', safe)
|
||||
root.style.setProperty('--yxg-page-top', `calc(${safe} + ${HEADER_H_PX}px)`)
|
||||
}
|
||||
|
||||
function readStoredSbh(): number {
|
||||
if (typeof sessionStorage === 'undefined') return 0
|
||||
const raw = sessionStorage.getItem(SBH_KEY)
|
||||
if (!raw || !/^\d+$/.test(raw)) return 0
|
||||
return parseInt(raw, 10)
|
||||
}
|
||||
|
||||
export function isMpNativeHeader(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return sessionStorage.getItem(MP_NATIVE_HEADER_KEY) === '1'
|
||||
}
|
||||
|
||||
export function applyMpEmbedDom(): void {
|
||||
if (typeof document === 'undefined' || !isMpEmbed()) return
|
||||
document.title = '\u200b'
|
||||
document.documentElement.classList.add('mp-embed')
|
||||
if (isMpNativeHeader()) {
|
||||
document.documentElement.classList.add('mp-native-header')
|
||||
const stored = readStoredSbh()
|
||||
if (stored > 0) {
|
||||
applyNativeHeaderInset(stored)
|
||||
} else {
|
||||
applyNativeHeaderMode()
|
||||
}
|
||||
return
|
||||
}
|
||||
const stored = readStoredSbh()
|
||||
if (stored > 0) applySafeTopInset(stored)
|
||||
}
|
||||
|
||||
export function initMpEmbed(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get(MP_QUERY) === '1') {
|
||||
sessionStorage.setItem(MP_EMBED_KEY, '1')
|
||||
}
|
||||
if (params.get(NH_QUERY) === '1') {
|
||||
sessionStorage.setItem(MP_NATIVE_HEADER_KEY, '1')
|
||||
const nativeSbh = params.get(SBH_QUERY)
|
||||
if (nativeSbh && /^\d+$/.test(nativeSbh)) {
|
||||
sessionStorage.setItem(SBH_KEY, nativeSbh)
|
||||
applyNativeHeaderInset(parseInt(nativeSbh, 10))
|
||||
} else {
|
||||
applyNativeHeaderMode()
|
||||
}
|
||||
}
|
||||
if (!isMpNativeHeader()) {
|
||||
const sbh = params.get(SBH_QUERY)
|
||||
if (sbh && /^\d+$/.test(sbh)) {
|
||||
sessionStorage.setItem(SBH_KEY, sbh)
|
||||
applySafeTopInset(parseInt(sbh, 10))
|
||||
}
|
||||
}
|
||||
applyMpEmbedDom()
|
||||
}
|
||||
|
||||
export function isMpEmbed(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
return sessionStorage.getItem(MP_EMBED_KEY) === '1'
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { initAnalytics, trackPageView } from './lib/analytics'
|
||||
import { initMpEmbed } from './lib/mpEmbed'
|
||||
|
||||
initMpEmbed()
|
||||
import '@yuxingu/ui/tokens.css'
|
||||
import './styles/app.css'
|
||||
|
||||
|
||||
@@ -48,14 +48,10 @@ const {
|
||||
.home {
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
padding-top: 48px;
|
||||
padding-top: var(--yxg-page-top);
|
||||
padding-bottom: var(--spacing-xs);
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(110% 70% at 85% -8%, rgba(255, 190, 170, 0.65) 0%, transparent 52%),
|
||||
radial-gradient(90% 55% at 8% 6%, rgba(255, 220, 200, 0.75) 0%, transparent 48%),
|
||||
radial-gradient(60% 40% at 50% 0%, rgba(255, 255, 255, 0.55) 0%, transparent 60%),
|
||||
linear-gradient(180deg, var(--color-bg-start) 0%, #ffe4d8 28%, #fff4ef 52%, var(--color-bg-sheet) 100%);
|
||||
background: var(--yxg-page-wash);
|
||||
}
|
||||
.atm {
|
||||
position: absolute;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { isMpEmbed } from '../lib/mpEmbed'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/psy/'),
|
||||
@@ -48,4 +49,25 @@ const router = createRouter({
|
||||
},
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (!isMpEmbed()) return true
|
||||
const q = { ...to.query }
|
||||
let changed = false
|
||||
if (q.mp !== '1') {
|
||||
q.mp = '1'
|
||||
changed = true
|
||||
}
|
||||
if (sessionStorage.getItem('yxg_mp_native_header') === '1' && q.nh !== '1') {
|
||||
q.nh = '1'
|
||||
changed = true
|
||||
}
|
||||
const storedSbh = sessionStorage.getItem('yxg_sbh')
|
||||
if (storedSbh && q.sbh !== storedSbh && q.nh !== '1') {
|
||||
q.sbh = storedSbh
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return true
|
||||
return { path: to.path, query: q, hash: to.hash, replace: true }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,22 +1,95 @@
|
||||
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
||||
html,body,#app{min-height:100%}
|
||||
html,body,#app{min-height:100%;min-height:100dvh}
|
||||
html{
|
||||
background-color:var(--color-bg-start);
|
||||
}
|
||||
body{
|
||||
font-family:var(--font-sans);
|
||||
font-size:14px;
|
||||
line-height:1.65;
|
||||
color:var(--color-text-primary);
|
||||
-webkit-font-smoothing:antialiased;
|
||||
background:
|
||||
background:var(--yxg-body-wash);
|
||||
background-attachment:fixed;
|
||||
}
|
||||
:root{
|
||||
--yxg-body-wash:
|
||||
radial-gradient(120% 80% at 80% -10%, rgba(255,180,160,.45) 0%, transparent 55%),
|
||||
radial-gradient(90% 60% at 10% 8%, rgba(255,210,190,.55) 0%, transparent 50%),
|
||||
linear-gradient(180deg, var(--yxg-bg-start) 0%, var(--yxg-bg-end) 28%, var(--color-bg-sheet) 100%);
|
||||
background-attachment:fixed;
|
||||
--yxg-page-wash:
|
||||
radial-gradient(110% 70% at 85% -8%, rgba(255, 190, 170, 0.65) 0%, transparent 52%),
|
||||
radial-gradient(90% 55% at 8% 6%, rgba(255, 220, 200, 0.75) 0%, transparent 48%),
|
||||
radial-gradient(60% 40% at 50% 0%, rgba(255, 255, 255, 0.55) 0%, transparent 60%),
|
||||
linear-gradient(180deg, var(--color-bg-start) 0%, #ffe4d8 28%, #fff4ef 52%, var(--color-bg-sheet) 100%);
|
||||
--yxg-header-h:50px;
|
||||
--yxg-page-top:calc(env(safe-area-inset-top,0px) + var(--yxg-header-h));
|
||||
--yxg-safe-top:env(safe-area-inset-top,0px);
|
||||
}
|
||||
.app-shell{
|
||||
max-width:var(--yxg-max-w);margin:0 auto;min-height:100vh;
|
||||
padding-bottom:calc(var(--yxg-nav-h) + env(safe-area-inset-bottom,0px) + 12px);
|
||||
max-width:var(--yxg-max-w);margin:0 auto;min-height:100vh;min-height:100dvh;
|
||||
display:flex;flex-direction:column;
|
||||
padding-bottom:calc(var(--yxg-nav-h) + env(safe-area-inset-bottom,0px));
|
||||
position:relative;
|
||||
}
|
||||
.app-body{
|
||||
flex:1 0 auto;
|
||||
position:relative;
|
||||
z-index:1;
|
||||
}
|
||||
html.mp-embed{
|
||||
background-color:var(--color-bg-start);
|
||||
background-image:var(--yxg-body-wash);
|
||||
background-attachment:fixed;
|
||||
}
|
||||
html.mp-embed body{
|
||||
background:transparent;
|
||||
}
|
||||
.app-shell.mp-embed{
|
||||
padding-bottom:env(safe-area-inset-bottom,0px);
|
||||
isolation:isolate;
|
||||
}
|
||||
.app-shell.mp-embed::before{
|
||||
content:"";
|
||||
position:fixed;
|
||||
inset:0;
|
||||
z-index:0;
|
||||
pointer-events:none;
|
||||
background:var(--yxg-body-wash);
|
||||
background-color:var(--color-bg-start);
|
||||
}
|
||||
.app-shell.mp-embed .home,
|
||||
.app-shell.mp-embed .ps{
|
||||
margin-top:0;
|
||||
padding-top:var(--yxg-page-top);
|
||||
min-height:100%;
|
||||
}
|
||||
/* 小程序原生顶栏:桃色顶区对齐 nav-bar,隐藏 H5 AppHeader,洗底不冲顶 */
|
||||
html.mp-embed.mp-native-header{
|
||||
background-color:#ffd1c7;
|
||||
background-image:none;
|
||||
}
|
||||
html.mp-embed.mp-native-header body{
|
||||
background:transparent;
|
||||
}
|
||||
html.mp-embed.mp-native-header .app-shell.mp-embed::before{
|
||||
background-color:#ffd1c7;
|
||||
}
|
||||
html.mp-embed.mp-native-header .app-header.back-only{
|
||||
display:block;
|
||||
}
|
||||
html.mp-embed.mp-native-header .app-header:not(.back-only){
|
||||
display:none !important;
|
||||
}
|
||||
html.mp-embed.mp-native-header .app-shell .home,
|
||||
html.mp-embed.mp-native-header .app-shell .ps,
|
||||
html.mp-embed.mp-native-header .app-shell .yxg-page{
|
||||
padding-top:var(--yxg-page-top);
|
||||
}
|
||||
.app-footer{
|
||||
position:relative;
|
||||
z-index:1;
|
||||
}
|
||||
a{color:inherit;text-decoration:none}
|
||||
|
||||
/* ── Page shell (测测式:暖色洗底 + 白 sheet) ── */
|
||||
@@ -261,4 +334,3 @@ a{color:inherit;text-decoration:none}
|
||||
.fc-c .yxg-feed-cover{background:linear-gradient(145deg,#D6E8FF,#B0D0F5);color:#3A6FB0}
|
||||
.fc-d .yxg-feed-cover{background:linear-gradient(145deg,#D8F0DC,#B5E0BE);color:#3A8A4A}
|
||||
.fc-e .yxg-feed-cover{background:linear-gradient(145deg,#FFE6C8,#F5C98A);color:#B07A28}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# 复制为 deploy.env(勿提交 deploy.env)
|
||||
# 用法: ./scripts/deploy-h5.sh 或 DEPLOY_ENV=./deploy.env ./scripts/deploy-h5.sh
|
||||
|
||||
H5_DEPLOY_USER=ubuntu
|
||||
H5_DEPLOY_HOST=your-server.example.com
|
||||
H5_DEPLOY_PATH=/var/www/h5.yuxingu.com.cn/psy
|
||||
@@ -3,8 +3,8 @@
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | BD-2026-044 |
|
||||
| Status | **Approved** |
|
||||
| Coding | **AUTHORIZED** |
|
||||
| Status | **Approved · Implemented** |
|
||||
| Coding | **Done · Closed** |
|
||||
| Level | L2 |
|
||||
| Migration | `000053_ops_rhythm_config_write`(编码时分配) |
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Backend Design: ECR-048 ReportTemplate Write
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | BD-2026-048 |
|
||||
| Status | **Closed** |
|
||||
| Coding | **Closed** |
|
||||
| Migration | `000057_ops_report_template_write` |
|
||||
|
||||
## Boundary
|
||||
|
||||
```text
|
||||
POST/PUT /api/v1/admin/growth/report-templates*
|
||||
Permission: admin.growth.write (reuse)
|
||||
Fields: code, title, scene, active
|
||||
system=true: code immutable
|
||||
Audit: growth.report_template.create|update
|
||||
No C-end route · no DELETE
|
||||
```
|
||||
|
||||
## Out
|
||||
|
||||
Funnel re-write · Prompt · soft-delete · auto ECR-049
|
||||
@@ -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
|
||||
@@ -1,7 +1,14 @@
|
||||
# CHANGELOG — ESS process artifacts
|
||||
|
||||
## 2026-09-14
|
||||
|
||||
- **ECR-050 Approved · Coding:** 咨询/测评/订单/支付迁入 Go(migration `000059`)· 去掉 Java 反代 · Human「阶段3,4一起实现再测试」· **禁自动 ECR-051**
|
||||
- **ECR-049 Approved · Coding:** 微信小程序登录 + 咨询过渡反代(ADR-0008 · migration `000058` · 不迁咨询业务)· Human「按这个执行」· **禁自动 ECR-050**
|
||||
|
||||
## 2026-08-13
|
||||
|
||||
- **ECR-048 Closed:** ReportTemplate 薄写(复用 `admin.growth.write` POST/PUT · migration `000057` · 无新 C 端)· **Loop STOP** · 禁自动 ECR-049 · 无 Funnel/Prompt
|
||||
- **ECR-048 Candidate Review Approved:** 首刀 = **ReportTemplate** 薄写(复用 `admin.growth.write`;编码占 `000057`)· Spec/Loop/Coding **未开** · Auth 草案 PENDING · **不得继承 047 Auth**
|
||||
- **ECR-047 Freeze/Review → FROZEN WAIT:** push 齐平 `580f5ea` · 单刀授权硬边界 · **禁自动 ECR-048** · 下一刀须 Candidate Review
|
||||
- **ECR-047 Authorized Closed:** Human `authorize ECR-047 Closed` · 接受本地 `1db5fcc`/`69cb996`/`000056` 为正式 Closed · **不含 push** · Loop STOP · 禁自动 ECR-048
|
||||
- **ECR-047 Closed:** FunnelDefinition 薄写(`admin.growth.write` POST/PUT · migration `000056` · 无新 C 端)· **Loop STOP** · 禁自动 ECR-048 · 无 ReportTemplate
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# CODE_REVIEW — ECR-048
|
||||
|
||||
**Verdict:** Approve · ReportTemplate 薄写 only · 无 Funnel/Prompt/soft-delete · Closed 后 STOP · ECR-008 Live Promote 仍 BLOCKED
|
||||
@@ -0,0 +1,18 @@
|
||||
ecr: ECR-048
|
||||
capability: GrowthInsights
|
||||
change:
|
||||
type: additive
|
||||
breaking_change: false
|
||||
migration_required: true
|
||||
apis:
|
||||
- method: POST
|
||||
path: /api/v1/admin/growth/report-templates
|
||||
change: added
|
||||
- method: PUT
|
||||
path: /api/v1/admin/growth/report-templates/{id}
|
||||
change: added
|
||||
perms:
|
||||
- code: admin.growth.write
|
||||
change: reused
|
||||
migration: 000057_ops_report_template_write
|
||||
notes: no C-end route; Funnel out of scope
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,544 @@
|
||||
# 愈心谷小程序 + 愈心魔方 H5 部署说明
|
||||
|
||||
适用于:**微信小程序**(`yuxingu-Miniprogram-dev`)嵌入 **愈心魔方 H5**(`digital-psychology/apps/user-h5`)的展示期上线。
|
||||
|
||||
**本地联调(免每次改服务器)** → 见 [LOCAL-DEV-MINI-PROGRAM-H5.md](LOCAL-DEV-MINI-PROGRAM-H5.md) · `npm run dev:mp`
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构
|
||||
|
||||
```
|
||||
微信小程序(uni-app)
|
||||
├── 首页 / 咨询 / 测评 / 我的 → https://miniapp.yuxingu.com.cn (Java,rp-server-psychic)
|
||||
└── 愈心魔方 Tab / 首页入口 → https://h5.yuxingu.com.cn/psy/?mp=1 (WebView)
|
||||
└── /psy/api/* → Go API(digital-psychology)
|
||||
```
|
||||
|
||||
| 组件 | 仓库 | 域名 |
|
||||
|------|------|------|
|
||||
| 小程序客户端 | `yuxingu-Miniprogram-dev` | —(微信后台上传) |
|
||||
| 愈心魔方 H5 | `digital-psychology/apps/user-h5` | `h5.yuxingu.com.cn` |
|
||||
| Go API | `digital-psychology/apps/api` | 同域反代 `/psy/api/` |
|
||||
| 旧业务 API | `rp-server-psychic`(独立部署) | `miniapp.yuxingu.com.cn` |
|
||||
|
||||
H5 地址配置在小程序 `util/yxgConfig.js`:
|
||||
|
||||
```js
|
||||
export const YXG_H5_BASE = 'https://h5.yuxingu.com.cn/psy/'
|
||||
export const YXG_H5_HOME_URL = `${YXG_H5_BASE}?mp=1`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 前置条件
|
||||
|
||||
### 2.1 域名与证书
|
||||
|
||||
| 域名 | 用途 | 要求 |
|
||||
|------|------|------|
|
||||
| `h5.yuxingu.com.cn` | H5 静态 + Go API 反代 | HTTPS、已备案 |
|
||||
| `miniapp.yuxingu.com.cn` | 咨询/测评/登录(已有) | HTTPS |
|
||||
|
||||
### 2.2 微信小程序后台
|
||||
|
||||
路径:**开发 → 开发管理 → 开发设置**
|
||||
|
||||
| 配置项 | 域名 |
|
||||
|--------|------|
|
||||
| **业务域名**(WebView 必配) | `h5.yuxingu.com.cn`(需上传校验文件到站点根目录) |
|
||||
| **request 合法域名** | `https://miniapp.yuxingu.com.cn`、`https://h5.yuxingu.com.cn` |
|
||||
| uploadFile 合法域名 | `https://miniapp.yuxingu.com.cn`(维持现状) |
|
||||
| downloadFile 合法域名 | `https://miniapp.yuxingu.com.cn`(维持现状) |
|
||||
| socket 合法域名 | 无需配置 |
|
||||
|
||||
### 2.3 服务器(Ubuntu 典型)
|
||||
|
||||
| 软件 | 用途 | 端口 |
|
||||
|------|------|------|
|
||||
| **MySQL** | Java 旧后台(`miniapp.yuxingu.com.cn`) | 3306 |
|
||||
| **PostgreSQL** | Go API 愈心魔方(**必须另装,不能复用 MySQL**) | 5432 |
|
||||
| **Nginx** | HTTPS、`h5.yuxingu.com.cn` 静态 + 反代 | 443 |
|
||||
| **Go 二进制** | `yuxingu-api`,建议仅监听 `127.0.0.1:8080` | 8080 |
|
||||
|
||||
Java 微服务 + `miniapp.yuxingu.com.cn` 保持现有部署,本说明不覆盖 Java/MySQL 的运维。
|
||||
|
||||
### 2.4 双数据库说明(Ubuntu 上已是 MySQL)
|
||||
|
||||
**愈心谷 Go API 只支持 PostgreSQL**(pgx + `apps/api/migrations/` 内 PG 语法)。
|
||||
**不能**把 `config.local.yaml` 指到 MySQL,也**不要**把 Java 的 MySQL 表直接给 Go 用。
|
||||
|
||||
| 业务 | 后端 | 数据库 |
|
||||
|------|------|--------|
|
||||
| 小程序:咨询 / 测评 / 订单 / 登录 | Java(rp-server-psychic) | **MySQL**(已有) |
|
||||
| 愈心魔方 H5:档案 / 问答 / 报告等 | Go(digital-psychology) | **PostgreSQL**(新增) |
|
||||
|
||||
推荐:**同一台 Ubuntu 上 MySQL 与 PostgreSQL 并存**(端口不同,互不影响)。
|
||||
|
||||
```
|
||||
Ubuntu VPS
|
||||
├── MySQL :3306 → miniapp.yuxingu.com.cn(不动)
|
||||
├── PostgreSQL :5432 → 愈心魔方 Go API(新增)
|
||||
├── yuxingu-api :8080 → 127.0.0.1(Nginx 反代)
|
||||
└── Nginx :443
|
||||
├── h5.yuxingu.com.cn/psy/ → user-h5 静态
|
||||
└── h5.yuxingu.com.cn/psy/api/ → Go API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. PostgreSQL 部署(Ubuntu)
|
||||
|
||||
Go API 启动前必须先有可用的 PostgreSQL 实例。
|
||||
|
||||
### 3.1 方式 A:系统包安装(推荐,与 MySQL 并存)
|
||||
|
||||
```bash
|
||||
# Ubuntu 22.04 / 24.04
|
||||
sudo apt update
|
||||
sudo apt install -y postgresql postgresql-contrib
|
||||
sudo systemctl enable postgresql
|
||||
sudo systemctl start postgresql
|
||||
|
||||
# 确认端口(默认 5432,与 MySQL 3306 不冲突)
|
||||
ss -tlnp | grep 5432
|
||||
```
|
||||
|
||||
创建专用库与用户(**请替换强密码**):
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql <<'SQL'
|
||||
CREATE USER yuxingu WITH PASSWORD '替换为强密码';
|
||||
CREATE DATABASE yuxingu OWNER yuxingu;
|
||||
GRANT ALL PRIVILEGES ON DATABASE yuxingu TO yuxingu;
|
||||
SQL
|
||||
```
|
||||
|
||||
仅本机访问(默认已是)时可加固:
|
||||
|
||||
```bash
|
||||
# 可选:确认 listen 仅 localhost(/etc/postgresql/*/main/postgresql.conf)
|
||||
# listen_addresses = 'localhost'
|
||||
sudo systemctl reload postgresql
|
||||
```
|
||||
|
||||
连通性自测:
|
||||
|
||||
```bash
|
||||
psql "postgresql://yuxingu:替换为强密码@127.0.0.1:5432/yuxingu?sslmode=disable" -c 'SELECT 1'
|
||||
```
|
||||
|
||||
### 3.2 方式 B:Docker 只跑 Postgres(系统 MySQL 不动)
|
||||
|
||||
已安装 Docker 时,可不必 `apt install postgresql`:
|
||||
|
||||
```bash
|
||||
docker run -d --name yuxingu-pg \
|
||||
--restart unless-stopped \
|
||||
-e POSTGRES_USER=yuxingu \
|
||||
-e POSTGRES_PASSWORD='替换为强密码' \
|
||||
-e POSTGRES_DB=yuxingu \
|
||||
-p 127.0.0.1:5432:5432 \
|
||||
-v yuxingu-pg-data:/var/lib/postgresql/data \
|
||||
postgres:16-alpine
|
||||
```
|
||||
|
||||
`config.local.yaml` 中 `database.host` 仍为 `127.0.0.1`,`port: 5432`。
|
||||
|
||||
### 3.3 方式 C:云托管 PostgreSQL
|
||||
|
||||
使用 RDS / 云数据库 PostgreSQL 时,将 `config.local.yaml` 的 `database.host` 改为云内网地址,并按云厂商要求设置 `sslmode`(常为 `require`)。
|
||||
安全组需允许 **应用服务器** 访问 5432,**不要**对公网开放 PostgreSQL。
|
||||
|
||||
### 3.4 迁移说明
|
||||
|
||||
- 表结构由 Go API **启动时自动迁移**(`apps/api/migrations/`),无需手工导入 SQL。
|
||||
- 首次 `./yuxingu-api` 或 `systemctl start yuxingu-api` 成功后,库内应有 `migrations` 相关表及业务表。
|
||||
- **禁止**把 Java MySQL 的 `psychic_*` 表导入 PostgreSQL 替代迁移。
|
||||
|
||||
---
|
||||
|
||||
## 4. Go API 部署
|
||||
|
||||
仓库:`digital-psychology`
|
||||
|
||||
### 4.1 服务器目录建议
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/yuxingu
|
||||
sudo chown "$USER:$USER" /opt/yuxingu
|
||||
|
||||
# 拉代码(示例)
|
||||
cd /opt/yuxingu
|
||||
git clone https://git.jackyu66.com/jack/digital-psychology.git
|
||||
cd digital-psychology/apps/api
|
||||
```
|
||||
|
||||
也可在本机构建 `yuxingu-api` 二进制 + scp 到服务器,仅同步 `apps/api/` 与 `config.local.yaml`。
|
||||
|
||||
### 4.2 构建
|
||||
|
||||
```bash
|
||||
cd /opt/yuxingu/digital-psychology/apps/api
|
||||
go test ./...
|
||||
go build -o yuxingu-api ./cmd/server
|
||||
```
|
||||
|
||||
服务器若无 Go toolchain,在开发机交叉编译后上传:
|
||||
|
||||
```bash
|
||||
# 开发机(Linux amd64 示例)
|
||||
GOOS=linux GOARCH=amd64 go build -o yuxingu-api ./cmd/server
|
||||
scp yuxingu-api user@server:/opt/yuxingu/digital-psychology/apps/api/
|
||||
```
|
||||
|
||||
### 4.3 配置(生产)
|
||||
|
||||
```bash
|
||||
cd /opt/yuxingu/digital-psychology/apps/api
|
||||
cp config.example.yaml config.local.yaml
|
||||
chmod 600 config.local.yaml
|
||||
```
|
||||
|
||||
`config.local.yaml` 示例(**PostgreSQL,非 MySQL**):
|
||||
|
||||
```yaml
|
||||
app:
|
||||
env: prod
|
||||
http_addr: "127.0.0.1:8080" # 仅本机,由 Nginx 反代对外
|
||||
|
||||
database:
|
||||
host: 127.0.0.1
|
||||
port: 5432
|
||||
user: yuxingu
|
||||
password: "替换为强密码"
|
||||
name: yuxingu
|
||||
sslmode: disable
|
||||
|
||||
deepseek:
|
||||
api_key: "" # Ask 真实回复需要;展示可先留空
|
||||
base_url: "https://api.deepseek.com"
|
||||
model: "deepseek-chat"
|
||||
timeout_sec: 60
|
||||
|
||||
admin:
|
||||
bootstrap_username: admin
|
||||
bootstrap_password: "替换为强密码"
|
||||
```
|
||||
|
||||
生产环境建议用环境变量 / 密钥管理覆盖敏感项,勿把 `config.local.yaml` 提交 Git。
|
||||
|
||||
### 4.4 首次启动与迁移
|
||||
|
||||
```bash
|
||||
cd /opt/yuxingu/digital-psychology/apps/api
|
||||
./yuxingu-api
|
||||
# 看到监听 127.0.0.1:8080、迁移无报错即可 Ctrl+C,改 systemd 常驻
|
||||
```
|
||||
|
||||
### 4.5 systemd 常驻
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/yuxingu-api.service
|
||||
[Unit]
|
||||
Description=Yuxingu Go API (愈心魔方)
|
||||
After=network.target postgresql.service
|
||||
Wants=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/opt/yuxingu/digital-psychology/apps/api
|
||||
ExecStart=/opt/yuxingu/digital-psychology/apps/api/yuxingu-api
|
||||
Environment=APP_ENV=prod
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
若 PostgreSQL 用 Docker 容器名 `yuxingu-pg`,将 `After=` 改为 `docker.service` 或去掉 PostgreSQL 依赖。
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable yuxingu-api
|
||||
sudo systemctl start yuxingu-api
|
||||
sudo systemctl status yuxingu-api
|
||||
```
|
||||
|
||||
### 4.6 健康检查
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:8080/api/v1/healthz
|
||||
# 期望:{"code":0,"data":{...}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. H5 构建与 Nginx 部署
|
||||
|
||||
### 5.1 构建静态资源
|
||||
|
||||
```bash
|
||||
cd /path/to/digital-psychology
|
||||
npm ci
|
||||
npm run build:h5
|
||||
# 产物:apps/user-h5/dist/
|
||||
```
|
||||
|
||||
构建产物 `base` 为 `/psy/`,与线上路径一致,**无需改 `baseURL`**(客户端请求 `/psy/api/v1/...`)。
|
||||
|
||||
### 5.2 上传到服务器
|
||||
|
||||
```bash
|
||||
rsync -avz --delete apps/user-h5/dist/ user@your-server:/var/www/yuxingu-h5/psy/
|
||||
```
|
||||
|
||||
目录结构示例:
|
||||
|
||||
```
|
||||
/var/www/yuxingu-h5/psy/
|
||||
├── index.html
|
||||
├── assets/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 5.3 Nginx 配置(推荐:H5 + API 同域)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name h5.yuxingu.com.cn;
|
||||
|
||||
ssl_certificate /etc/ssl/h5.yuxingu.com.cn/fullchain.pem;
|
||||
ssl_certificate_key /etc/ssl/h5.yuxingu.com.cn/privkey.pem;
|
||||
|
||||
# 微信业务域名校验文件(按后台提示的文件名放置)
|
||||
location = /MP_verify_xxxx.txt {
|
||||
root /var/www/wechat-verify;
|
||||
}
|
||||
|
||||
# user-h5 SPA(history 模式)
|
||||
location /psy/ {
|
||||
alias /var/www/yuxingu-h5/psy/;
|
||||
try_files $uri $uri/ /psy/index.html;
|
||||
}
|
||||
|
||||
# Go API 反代:/psy/api/v1/* → :8080/api/v1/*
|
||||
location /psy/api/ {
|
||||
rewrite ^/psy/api/(.*)$ /api/$1 break;
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# Ask 流式响应
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location = / {
|
||||
return 301 https://$host/psy/;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
reload:
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 5.4 H5 embed 模式说明
|
||||
|
||||
小程序 WebView 打开 `https://h5.yuxingu.com.cn/psy/?mp=1` 时:
|
||||
|
||||
- H5 **隐藏底部 TabBar**(仅小程序底栏导航)
|
||||
- 子页路由自动保留 `mp=1` 参数
|
||||
- 子页显示顶部返回键,不显示 H5 主导航
|
||||
|
||||
浏览器直访 `https://h5.yuxingu.com.cn/psy/`(无 `mp=1`)仍为完整 H5 体验(含 TabBar)。
|
||||
|
||||
### 5.5 ICP 备案页脚
|
||||
|
||||
`user-h5` / `admin-h5` 构建产物已含底部链接:**蜀ICP备2025140386号-2** → `https://beian.miit.gov.cn/`。
|
||||
部署 H5 后访问 `https://h5.yuxingu.com.cn/psy/` 应能在页面底部看到备案号。
|
||||
|
||||
---
|
||||
|
||||
## 6. 运营后台 admin-h5(可选)
|
||||
|
||||
若需部署内部 Ops 后台(与愈心魔方同 Go API):
|
||||
|
||||
```bash
|
||||
cd /path/to/digital-psychology
|
||||
npm ci
|
||||
npm run build:admin
|
||||
# 产物:apps/admin-h5/dist/
|
||||
```
|
||||
|
||||
Nginx 可增加 location(与 H5 同机示例):
|
||||
|
||||
```nginx
|
||||
location /psy/admin/ {
|
||||
alias /var/www/yuxingu-admin/dist/;
|
||||
try_files $uri $uri/ /psy/admin/index.html;
|
||||
}
|
||||
```
|
||||
|
||||
admin 前端请求走 `/psy/api/` 或 `/api/` 反代(与 `apps/admin-h5` 构建 `base` 一致)。
|
||||
登录使用 `config.local.yaml` 中 `admin.bootstrap_*` 初始账号。
|
||||
|
||||
---
|
||||
|
||||
## 7. 微信小程序部署
|
||||
|
||||
仓库:`yuxingu-Miniprogram-dev`
|
||||
|
||||
### 7.1 开发工具构建
|
||||
|
||||
1. 用 **HBuilderX** 打开 `yuxingu-Miniprogram-dev`
|
||||
2. 菜单:**发行 → 小程序-微信**
|
||||
3. 或使用 CLI(需已安装 `@dcloudio/uni-cli` 等依赖):
|
||||
|
||||
```bash
|
||||
cd /path/to/yuxingu-Miniprogram-dev
|
||||
# 具体命令以 HBuilderX / uni-app 版本为准
|
||||
npx uni build -p mp-weixin
|
||||
```
|
||||
|
||||
编译产物目录:`unpackage/dist/build/mp-weixin/`(或 dev 目录)
|
||||
|
||||
### 7.2 微信开发者工具
|
||||
|
||||
1. 导入上述 `mp-weixin` 目录
|
||||
2. AppID:`wx6782dd88e655f1a7`(见 `manifest.json`)
|
||||
3. 预览 / 真机调试
|
||||
|
||||
### 7.3 上传体验版 / 正式版
|
||||
|
||||
1. 开发者工具 → **上传**
|
||||
2. 登录 [微信公众平台](https://mp.weixin.qq.com/) → **管理 → 版本管理**
|
||||
3. 将上传版本设为 **体验版** 供内部验收,或提交审核后发布
|
||||
|
||||
### 7.4 发布前检查(小程序侧)
|
||||
|
||||
- [ ] `util/yxgConfig.js` 中 H5 地址为生产域名
|
||||
- [ ] `util/requestConfig.js` 中 Java API 仍为 `https://miniapp.yuxingu.com.cn`
|
||||
- [ ] Tab 顺序:首页 | 愈心魔方 | 心理咨询 | 心理测评 | 我的
|
||||
- [ ] `static/n-menu/mf-*.png` 图标已替换为正式稿(当前可为占位图)
|
||||
|
||||
---
|
||||
|
||||
## 8. Ubuntu 一键部署顺序(Checklist)
|
||||
|
||||
按顺序执行,避免愈心魔方 Tab 白屏:
|
||||
|
||||
```text
|
||||
□ 1. MySQL + Java(miniapp)已运行 — 无需本说明改动
|
||||
□ 2. 安装 PostgreSQL(§3.1 或 §3.2),建库 yuxingu
|
||||
□ 3. 拉取 digital-psychology,构建 yuxingu-api(§4)
|
||||
□ 4. 编写 config.local.yaml(database 指向 PostgreSQL:5432)
|
||||
□ 5. systemctl start yuxingu-api,curl healthz 通
|
||||
□ 6. 本机 npm run build:h5,rsync 到 /var/www/yuxingu-h5/psy/
|
||||
□ 7. 配置 Nginx(§5.3),reload,curl https://h5.../psy/api/v1/healthz
|
||||
□ 8. 浏览器打开 https://h5.yuxingu.com.cn/psy/?mp=1,确认备案号与页面
|
||||
□ 9. HBuilderX 发行小程序,上传体验版
|
||||
□ 10. 真机:愈心魔方 Tab + 咨询/测评 Tab 均正常
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 上线验证清单
|
||||
|
||||
### 9.1 服务端
|
||||
|
||||
```bash
|
||||
# H5 首页
|
||||
curl -I https://h5.yuxingu.com.cn/psy/
|
||||
|
||||
# API 健康
|
||||
curl -sS https://h5.yuxingu.com.cn/psy/api/v1/healthz
|
||||
|
||||
# embed 首页可访问
|
||||
curl -I 'https://h5.yuxingu.com.cn/psy/?mp=1'
|
||||
```
|
||||
|
||||
### 9.2 微信开发者工具 / 真机
|
||||
|
||||
| 步骤 | 预期 |
|
||||
|------|------|
|
||||
| 打开小程序首页 | Banner、愈心魔方紫色入口条、测评/咨询卡片正常 |
|
||||
| 点击「愈心魔方」入口 | 切到第 2 个 Tab,加载 H5 |
|
||||
| 点击底部「愈心魔方」Tab | 同上 |
|
||||
| H5 内点九宫格进子页(如问答) | 无 H5 底栏,有返回键,接口正常 |
|
||||
| 咨询 / 测评 Tab | 仍走 miniapp 旧接口,登录与订单正常 |
|
||||
| 未登录访问需登录能力 | 弹出微信手机号登录 |
|
||||
|
||||
### 9.3 常见故障
|
||||
|
||||
| 现象 | 原因 | 处理 |
|
||||
|------|------|------|
|
||||
| WebView 白屏 | 业务域名未配或校验文件缺失 | 微信后台 → 业务域名 → 重新校验 |
|
||||
| H5 内接口失败 | request 合法域名缺 `h5.yuxingu.com.cn` | 补充合法域名,等 5 分钟生效 |
|
||||
| 404 on `/psy/ask` 刷新 | Nginx 未配 SPA fallback | 确认 `try_files` 指向 `/psy/index.html` |
|
||||
| API 502 | Go 未启动或反代路径错误 | 查 `journalctl -u yuxingu-api` 与 Nginx `proxy_pass` |
|
||||
| API 连库失败 | 误配 MySQL 或 PG 密码/端口错 | `database` 必须 PostgreSQL;`psql` 自测 |
|
||||
| H5 仍显示底栏 | 未带 `mp=1` | 检查 `yxgConfig.js` 与 WebView URL |
|
||||
| 双层底栏 | H5 未进 embed 模式 | 确认 URL 含 `?mp=1` 且已部署最新 H5 |
|
||||
| 迁移失败 | PG 权限不足 | `GRANT ALL ON DATABASE yuxingu TO yuxingu` |
|
||||
|
||||
---
|
||||
|
||||
## 10. 更新与回滚
|
||||
|
||||
### 仅更新愈心魔方(H5 + API)
|
||||
|
||||
```bash
|
||||
# 1. 构建 H5
|
||||
cd digital-psychology && npm run build:h5
|
||||
rsync -avz --delete apps/user-h5/dist/ user@server:/var/www/yuxingu-h5/psy/
|
||||
|
||||
# 2. 更新 API(需停服瞬间)
|
||||
cd apps/api && go build -o yuxingu-api ./cmd/server
|
||||
sudo systemctl restart yuxingu-api
|
||||
```
|
||||
|
||||
小程序 **WebView 无缓存强制刷新**时,用户可能仍看到旧 H5;可在 URL 加版本 query(如 `?mp=1&v=20260824`)或在微信公众平台重新发布小程序。
|
||||
|
||||
### 仅更新小程序壳
|
||||
|
||||
重新 HBuilderX 发行 → 上传新版本。不影响 H5 与 API。
|
||||
|
||||
### 回滚
|
||||
|
||||
- H5:恢复 `/var/www/yuxingu-h5/psy/` 备份目录
|
||||
- API:`systemctl restart` 旧二进制
|
||||
- 小程序:版本管理 → 回退到上一审核版本
|
||||
|
||||
---
|
||||
|
||||
## 11. 仓库与路径速查
|
||||
|
||||
| 内容 | 路径 |
|
||||
|------|------|
|
||||
| 小程序 Tab / 首页入口 | `yuxingu-Miniprogram-dev/pages/yxg-magic/`、`pages/index/index.vue` |
|
||||
| H5 地址常量 | `yuxingu-Miniprogram-dev/util/yxgConfig.js` |
|
||||
| H5 embed 逻辑 | `digital-psychology/apps/user-h5/src/lib/mpEmbed.ts` |
|
||||
| Go API | `digital-psychology/apps/api/cmd/server` |
|
||||
| H5 构建命令 | `digital-psychology` 根目录 `npm run build:h5` |
|
||||
| 本地开发 | Go:`cd apps/api && go run ./cmd/server`;H5:`npm run dev:h5` |
|
||||
|
||||
| ICP 常量 | `packages/utils/src/icp.ts` |
|
||||
| 部署本文 | `docs/DEPLOY-MINI-PROGRAM-H5.md` |
|
||||
|
||||
---
|
||||
|
||||
## 12. 后续(非展示期)
|
||||
|
||||
- 微信登录与 Go Auth 统一(单账号)
|
||||
- Java 顾问/测评域逐步迁入 Go API
|
||||
- CI 镜像发布见 `.ai/deployment.md`、`deploy/README.md`
|
||||
@@ -0,0 +1,20 @@
|
||||
# ECR-048
|
||||
|
||||
**Title:** GrowthInsights · ReportTemplate 薄写面
|
||||
**Status:** **Closed**(2026-08-13)
|
||||
**Auth:** `ECR-048_WRITE_AUTHORIZATION.md` · Closed 后 STOP
|
||||
|
||||
## Change
|
||||
|
||||
1. Spec `ops-report-template-write`
|
||||
2. POST/PUT `/admin/growth/report-templates*` + `admin.growth.write` + 审计
|
||||
3. 无新 C 端路由
|
||||
4. Migration `000057` · OpenAPI · admin-h5
|
||||
|
||||
## Forbidden
|
||||
|
||||
Funnel 再写 · Prompt 族 · soft-delete · 支付/UGC · 自动 ECR-049
|
||||
|
||||
## Acceptance
|
||||
|
||||
TEST_REPORT/ECR-048 · CODE_REVIEW Approve · Done · Closed.
|
||||
@@ -0,0 +1,25 @@
|
||||
# ECR-049
|
||||
|
||||
**Canonical-ID:** `ECR-049`
|
||||
**Title:** 微信小程序登录 + 咨询过渡网关
|
||||
**Status:** **Approved · Coding**
|
||||
**Auth:** `ECR-049_WRITE_AUTHORIZATION.md`
|
||||
|
||||
## Change
|
||||
|
||||
1. Spec `account-auth`:小程序登录改为微信;游客仍不可出报告
|
||||
2. ADR-0008:Go 为唯一用户后台;Java 过渡期为被调方
|
||||
3. `POST /api/v1/auth/wechat` + migration `000058`
|
||||
4. Go 反代 `/api/app-api/*`、`/api/admin-api/*` 到 Java(带内网签发的 Java Bearer)
|
||||
5. 小程序(另仓)收成单 token / 单 base / 唯一 `login-pop`
|
||||
|
||||
## Forbidden
|
||||
|
||||
咨询/测评/订单/支付迁库 · 下线 Java/MySQL · 改 Admin 登录 · Write-Wave 续刀 · 自动 ECR-050
|
||||
|
||||
## Acceptance
|
||||
|
||||
- 微信 `code` 只打 Go 一次;咨询接口经反代仍可用
|
||||
- 同一 openid/手机号在魔方与咨询认同一人
|
||||
- 未配置微信密钥时接口明确失败,不回落到账密
|
||||
- `repo-governance-check.py` PASS · Max Migration=`000058`
|
||||
@@ -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-048
|
||||
|
||||
Auth Approved · Spec Ready · BD Approved · ReportTemplate ONLY · migration 000057 · Closed → STOP.
|
||||
@@ -0,0 +1,4 @@
|
||||
# HANDOFF — Engineer → Reviewer · ECR-048
|
||||
|
||||
TestGrowthReportTemplateWrite PASS · governance max 000057 · Ready Closed.
|
||||
无 Funnel/Prompt · STOP · 禁自动 ECR-049.
|
||||
@@ -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,56 @@
|
||||
# 愈心魔方 H5 · 本地联调(免每次改服务器)
|
||||
|
||||
改 H5 时**不必**先部署线上;用本机 Vite + 微信开发者工具即可预览。
|
||||
|
||||
## 日常流程
|
||||
|
||||
```bash
|
||||
# 终端 1:H5(会打印局域网 IP)
|
||||
cd digital-psychology
|
||||
npm run dev:mp
|
||||
|
||||
# 终端 2(需要登录/接口时)
|
||||
npm run dev:api
|
||||
# 或仅数据库: npm run deps:up && npm run dev:api
|
||||
```
|
||||
|
||||
```text
|
||||
微信开发者工具
|
||||
├── 打开 yuxingu-Miniprogram-dev
|
||||
├── 详情 → 本地设置 → 勾选「不校验合法域名、web-view…」
|
||||
├── 编译运行(开发模式,非发行)
|
||||
└── 打开 Tab「愈心魔方」→ 自动加载本机 http://<你的IP>:5173/psy/
|
||||
```
|
||||
|
||||
小程序侧规则(已配置):
|
||||
|
||||
- **开发编译**(`import.meta.env.DEV`)→ `util/yxgConfig.local.js` 里的局域网地址
|
||||
- **发行 / 上传体验版** → 自动使用 `https://h5.yuxingu.com.cn/psy/`
|
||||
|
||||
只需改一次 `yuxingu-Miniprogram-dev/util/yxgConfig.local.js`:
|
||||
|
||||
```js
|
||||
export const LOCAL_H5_HOST = '192.168.x.x:5173' // 与 dev:mp 打印一致
|
||||
```
|
||||
|
||||
## 浏览器自测 embed
|
||||
|
||||
```text
|
||||
http://localhost:5173/psy/?mp=1&sbh=88
|
||||
```
|
||||
|
||||
## 确认要上线时(一条命令)
|
||||
|
||||
```bash
|
||||
cp deploy.env.example deploy.env # 首次填写服务器
|
||||
chmod +x scripts/deploy-h5.sh
|
||||
./scripts/deploy-h5.sh
|
||||
```
|
||||
|
||||
## 局限
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 真机预览 | 手机与电脑同一 WiFi;开发者工具可勾选「真机调试」 |
|
||||
| 业务域名 | 本地为 `http`,仅开发模式 + 关闭域名校验时可用 |
|
||||
| 与线上一致 | 发行前仍建议在体验版走 HTTPS 域名验收一次 |
|
||||
@@ -0,0 +1,3 @@
|
||||
# PRODUCT_SPEC — ECR-048
|
||||
|
||||
对齐 `ops-report-template-write.md` · **Closed**.
|
||||
@@ -0,0 +1,5 @@
|
||||
# PRODUCT_SPEC — ECR-049
|
||||
|
||||
小程序 C 端登录改为微信手机号授权;Go 为身份源。
|
||||
咨询/测评/订单仍走 Java,经 Go 反代,本刀不迁业务表。
|
||||
H5 OpenLogin 过渡保留。Admin 不动。
|
||||
@@ -53,21 +53,23 @@
|
||||
- **本仓:** `ess_intake: strict` · Retro FAIL · 单 ECR Context
|
||||
- **Ops foundation:** `docs/WAVE0/` · `.ai/domain/boundary-rules.md` · `glossary.yaml` · Loop: `docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||
- ECR: main 线 ECR-006–016 Closed;Ops 扩展 ECR-013A/B · 017–040;分叉已固定为 `ECR-012-star` / `014-plan` / `015-code` / `016-insight`(见 TRACEABILITY)
|
||||
- **Next ECR / Max Migration:** **Next=`ECR-048`(禁自动开)** · **Max=`000056`**
|
||||
- **Next ECR / Max Migration:** **Next=`ECR-051`(禁自动开)** · **Max=`000059`**
|
||||
- **Write-Wave CMS:** 041–042 Closed · STOP
|
||||
- **ExploreConfig:** ECR-043…046 Closed · 同构写面 **耗尽 · STOP** · Prompt/Knowledge/Chunk STOP
|
||||
- **GrowthInsights:** ECR-047 FunnelDefinition **Closed · Authorized · Pushed**(`580f5ea`)· **FROZEN WAIT** · 禁自动 ECR-048 · ReportTemplate/Prompt STOP
|
||||
- **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…047)
|
||||
- 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`
|
||||
- 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)
|
||||
|
||||
- **FROZEN WAIT**(ECR-047 Freeze/Review 收口)· 下一刀须 `ECR-048 Candidate Review` + Human 单独授权 · **禁止** Spec→Code 直开
|
||||
- (无 · ECR-048 Closed · Loop STOP)
|
||||
- BD-2026-044 状态补丁:工作区可暂留 · **不**自动提交
|
||||
|
||||
## Pointers
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# STATE — ECR-048
|
||||
|
||||
| Status | **Closed** |
|
||||
| Spec | ops-report-template-write |
|
||||
| Migration | 000057_ops_report_template_write |
|
||||
| Closed | 2026-08-13 |
|
||||
| Next | STOP · ECR-049 禁自动开 |
|
||||
@@ -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 禁自动开 |
|
||||
@@ -0,0 +1,7 @@
|
||||
# STATE — ECR-050
|
||||
|
||||
| Status | **Implemented · Ready Review** |
|
||||
| Spec | consult-miniprogram |
|
||||
| Migration | 000059_consult_native |
|
||||
| Owner | ENGINEER |
|
||||
| Next | STOP · ECR-051 禁自动开 |
|
||||
@@ -0,0 +1,11 @@
|
||||
# TEST_REPORT — ECR-048 ReportTemplate Write
|
||||
|
||||
**Commit:** `e0f605558b5cd5c2d9462449650d5a0d89116763`
|
||||
|
||||
```bash
|
||||
go test ./internal/integration/ -count=1 -run TestGrowthReportTemplateWrite
|
||||
python3 scripts/repo-governance-check.py
|
||||
```
|
||||
|
||||
PASS · create · 409 · PUT 下架 · audit · 403
|
||||
REPO GOVERNANCE: PASS · max 000057 · Next ECR-049(禁自动开)
|
||||
@@ -0,0 +1,13 @@
|
||||
# TEST_REPORT — ECR-049
|
||||
|
||||
Date: 2026-09-14
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `python scripts/repo-governance-check.py` | PASS · max 000058 · Next ECR-050 |
|
||||
| `go test ./internal/wechat` | PASS(AES 手机号解密往返) |
|
||||
| `go build ./...`(apps/api) | PASS |
|
||||
| 微信真机 `code` + 咨询反代 | **未测**(需 AppId/Secret + Java `auth.internal-issue-key` + 现网网关) |
|
||||
| 小程序微信开发者工具点选 | **未测**(本环境无该工具) |
|
||||
|
||||
未做:咨询业务迁库、支付、下线 Java。
|
||||
@@ -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,8 +4,8 @@
|
||||
|
||||
| Anchor | Value | Rule |
|
||||
|--------|-------|------|
|
||||
| **Next ECR** | `ECR-048` | **冻结:** 须重新 Candidate Review + Human 单独拍板;**禁止自动开刀** |
|
||||
| **Max Migration** | `000056` | 禁止凭记忆 |
|
||||
| **Next ECR** | `ECR-051` | **冻结:** 须重新 Candidate Review + Human 单独拍板;**禁止自动开刀** |
|
||||
| **Max Migration** | `000059` | 禁止凭记忆 |
|
||||
|
||||
- ECR identity **全局唯一**(含已 Closed);禁止同号双义。分叉只用 `ECR-NNN-suffix` / `ECR-NNNA`。
|
||||
- Migration **6 位版本号唯一**。撞号修复见 `docs/MIGRATION_RENUMBER.md`。
|
||||
@@ -72,6 +72,9 @@
|
||||
| ECR-044 | ExploreConfig · RhythmConfig 薄写面 | **Closed** | Spec ops-rhythm-config-write · BD-2026-044 · migration **000053** · Closed 后 STOP · 禁自动 ECR-045 |
|
||||
| ECR-045 | ExploreConfig · ImageCardDeck 薄写面 | **Closed** | Spec ops-image-card-deck-write · BD-2026-045 · migration **000054** · Closed 后 STOP · 禁自动 ECR-046 · 无牌面编辑 |
|
||||
| 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=`580f5ea` · **禁自动 ECR-048** |
|
||||
| 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` 为准(文档中旧号引用可能滞后)。
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# ECR-048 Candidate Review — GrowthInsights · ReportTemplate
|
||||
|
||||
> **RESOLVED 2026-08-13(Human: Candidate Review → Auth/Spec/开码 → Closed):**
|
||||
> 首刀 = **ReportTemplate 薄写** · **Closed** · Loop **STOP** · 禁自动 ECR-049
|
||||
|
||||
**Date:** 2026-08-13
|
||||
**Mode:** Candidate Review **Approved** · Spec **Closed** · Coding **Closed**
|
||||
**Anchors:** Next=`ECR-049`(禁自动开)· Max Migration=`000057`
|
||||
|
||||
## 锁定现状
|
||||
|
||||
| 项 | 状态 |
|
||||
|----|------|
|
||||
| ECR-047 | FunnelDefinition **Closed · Authorized · Pushed** · Loop **STOP** |
|
||||
| ECR-038 | ReportTemplate **只读** Closed |
|
||||
| `admin.growth.write` | **已存在**(047 授予) |
|
||||
| Continuous Loop | **STOP**(047 Auth 耗尽 · **不得继承**) |
|
||||
| 硬 STOP | 支付 / UGC / soft-delete / Crisis·Handoff / Prompt / Knowledge / Chunk |
|
||||
| ECR-008 Live Promote | **BLOCKED** |
|
||||
|
||||
## 八问(Candidate Review 强制)
|
||||
|
||||
| # | 问 | 答 |
|
||||
|---|----|----|
|
||||
| 1 | 做什么能力? | **ReportTemplate 薄写**(`code` / `title` / `scene` / `active`) |
|
||||
| 2 | 为什么现在做? | GrowthInsights 只读目录写面剩 ReportTemplate;与 047 Funnel **同构**,可验证 `growth.write` 复用 |
|
||||
| 3 | 是否属当前 Write-Wave? | **GrowthInsights 写面续刀**(非 CMS / 非 ExploreConfig / 非 AICore) |
|
||||
| 4 | 风险等级? | **低–中**:目录/开关;无新 C 端路由;不改模型行为 |
|
||||
| 5 | 是否需要新权限? | **否** · 复用 `admin.growth.write`(047 已授) |
|
||||
| 6 | 是否需要 migration? | **是** · 占 **000057**(幂等确认 growth.write;与 053–055 同模式) |
|
||||
| 7 | 是否触碰硬 STOP? | **否**(排除 Prompt/Knowledge/Chunk/支付/UGC/soft-delete/Crisis) |
|
||||
| 8 | 是否值得继续? | **是** · 对称收口 GrowthInsights 两表写面;Closed 后仍须 STOP |
|
||||
|
||||
## 概念候选
|
||||
|
||||
| 优先 | 概念 | 备注 |
|
||||
|------|------|------|
|
||||
| **★ 决议首刀** | **ReportTemplate** | 表 `report_templates`;复用 growth.write |
|
||||
| ✕ 排除 | Funnel 再写 | 047 Closed · 勿回流 |
|
||||
| ✕ 排除 | SystemPrompt / Knowledge / Chunk | **硬 STOP** |
|
||||
| ✕ 排除 | CMS / Explore / 题干 / Live Promote | 禁 |
|
||||
|
||||
## 决议
|
||||
|
||||
**ECR-048 = GrowthInsights · ReportTemplate 薄写面**
|
||||
|
||||
拟议(Spec 细化):
|
||||
|
||||
- 复用表 `report_templates`
|
||||
- 复用 `admin.growth.write`
|
||||
- Admin `POST/PUT /admin/growth/report-templates*`
|
||||
- 字段仅 `code` · `title` · `scene` · `active`
|
||||
- `system=true`:禁止改 `code` / 物理删
|
||||
- 审计 `growth.report_template.create|update`
|
||||
- **无**新 C 端路由
|
||||
- Migration:编码时占 **000057**
|
||||
- Continuous Loop:**仅**单独 Auth 且 **仅限 ECR-048**;Closed → STOP · **禁自动 ECR-049**
|
||||
|
||||
## 硬 STOP
|
||||
|
||||
Prompt / Knowledge / Chunk · 支付 · UGC · soft-delete · Crisis/Handoff · 题干 · Funnel 再写 · ECR-008 Live Promote 解禁 · 继承 047 Auth · 自动下一刀
|
||||
|
||||
## 下一步(仍不写码)
|
||||
|
||||
1. ~~ECR-048 Candidate Review approve~~ **Done · ReportTemplate**
|
||||
2. Human:批准 **ECR-048 专用** Write Authorization(例:`批准 ECR-048 专用 Write Auth → Spec → 开码`)
|
||||
3. Spec `ops-report-template-write` · ECR · BD
|
||||
4. 开码口令后才改 `apps/`
|
||||
|
||||
**本回合交付 = Candidate Review RESOLVED;无 Spec · 无 Auth Loop · 无 `apps/`。**
|
||||
@@ -0,0 +1,27 @@
|
||||
# GrowthInsights Write Authorization — ECR-048 only
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Date | 2026-08-13 |
|
||||
| Authorizer | Human |
|
||||
| Status | **Approved · ECR-048 Closed · Loop STOP** |
|
||||
| Scope | **ReportTemplate 薄写面 ONLY** |
|
||||
| Continuous Loop | **Finished · STOP** |
|
||||
| After Closed | **STOP** · 禁止自动 ECR-049 |
|
||||
|
||||
## Human 拍板(2026-08-13)
|
||||
|
||||
1. Candidate Review = ReportTemplate
|
||||
2. Write Auth → Spec → 开码
|
||||
3. 复用 `admin.growth.write` · 字段 code/title/scene/active · 无新 C 端路由
|
||||
4. Continuous Loop **仅限 ECR-048** · **不得继承 047 Auth**
|
||||
|
||||
## Hard STOP
|
||||
|
||||
Funnel 再写 · Prompt / Knowledge / Chunk · 支付 · UGC · soft-delete · Crisis/Handoff · Closed 后自动下一刀 · ECR-008 Live Promote 解禁
|
||||
|
||||
## Refs
|
||||
|
||||
Candidate Review: `ECR-048-CANDIDATE_REVIEW.md`
|
||||
Spec: `.ai/product/feature-spec/ops-report-template-write.md`
|
||||
ECR: `docs/ECR/ECR-048-report-template-write.md`
|
||||
@@ -0,0 +1,16 @@
|
||||
# Candidate Review — ECR-049
|
||||
|
||||
> 首刀 = **微信登录 + 咨询反代(身份源切 Go)** · Human 2026-09-14 拍板
|
||||
|
||||
**Anchors:** Next=`ECR-049`(Human 单独开刀,非 Write-Wave 自动续)· Max Migration=`000057` → 编码占 `000058`
|
||||
|
||||
| # | 问题 | 结论 |
|
||||
|---|---|---|
|
||||
| 1 | 是否新 C 端行为? | 是 · 小程序微信登录打 Go;咨询请求经 Go 反代 |
|
||||
| 2 | 是否新 API / 新表? | 是 · `POST /api/v1/auth/wechat` · `users.wx_*` · 反代 `/api/app-api/*` |
|
||||
| 3 | 是否换栈? | 否 · 仍 Go + PG;Java 过渡期保留 |
|
||||
| 4 | 是否迁咨询业务? | **否** · 本刀只做身份 + 网关 |
|
||||
| 5 | H5 账密? | 过渡保留 OpenLogin,避免 H5 断服;小程序不再走账密 |
|
||||
| 6 | 是否需要 migration? | **是** · 占 **000058** |
|
||||
|
||||
Closed → STOP · 禁自动 ECR-050
|
||||
@@ -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 登录
|
||||
@@ -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 进程(须运维另做)
|
||||
@@ -9,7 +9,9 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev:h5": "npm run dev -w @yuxingu/user-h5",
|
||||
"dev:mp": "bash scripts/dev-mp-h5.sh",
|
||||
"build:h5": "npm run build -w @yuxingu/user-h5",
|
||||
"deploy:h5": "bash scripts/deploy-h5.sh",
|
||||
"dev:admin": "npm run dev -w @yuxingu/admin-h5",
|
||||
"build:admin": "npm run build -w @yuxingu/admin-h5",
|
||||
"dev:api": "cd apps/api && go run ./cmd/server",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** 工信部 ICP 备案(全站 H5 / 后台页脚) */
|
||||
export const ICP_RECORD = '蜀ICP备2025140386号-2'
|
||||
|
||||
/** 工信部备案查询入口 */
|
||||
export const ICP_MIIT_URL = 'https://beian.miit.gov.cn/'
|
||||
@@ -25,3 +25,5 @@ export {
|
||||
type UserTextKind,
|
||||
type ValidateUserTextResult,
|
||||
} from './textsafe'
|
||||
|
||||
export { ICP_MIIT_URL, ICP_RECORD } from './icp'
|
||||
|
||||
@@ -1408,6 +1408,21 @@ paths:
|
||||
description: Unauthorized
|
||||
'403':
|
||||
description: Forbidden
|
||||
post:
|
||||
tags: [admin]
|
||||
summary: Create ReportTemplate
|
||||
description: Requires admin.growth.write · ECR-048 · code/title/scene/active only
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
'400':
|
||||
description: Invalid
|
||||
'401':
|
||||
description: Unauthorized
|
||||
'403':
|
||||
description: Forbidden
|
||||
'409':
|
||||
description: Code conflict
|
||||
|
||||
/api/v1/admin/growth/report-templates/{id}:
|
||||
get:
|
||||
@@ -1423,6 +1438,26 @@ paths:
|
||||
description: OK
|
||||
'404':
|
||||
description: Not found
|
||||
put:
|
||||
tags: [admin]
|
||||
summary: Update ReportTemplate
|
||||
description: Requires admin.growth.write · deactivate via active=false · ECR-048
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
'400':
|
||||
description: Invalid
|
||||
'403':
|
||||
description: Forbidden
|
||||
'404':
|
||||
description: Not found
|
||||
'409':
|
||||
description: Code conflict
|
||||
|
||||
/api/v1/admin/analytics/funnel-definitions:
|
||||
get:
|
||||
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# 构建并 rsync 上传 user-h5 dist(配置见 deploy.env.example)
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
ENV_FILE="${DEPLOY_ENV:-$ROOT/deploy.env}"
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "缺少 $ENV_FILE — 请复制 deploy.env.example 为 deploy.env 并填写服务器信息" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
|
||||
: "${H5_DEPLOY_USER:?在 deploy.env 设置 H5_DEPLOY_USER}"
|
||||
: "${H5_DEPLOY_HOST:?在 deploy.env 设置 H5_DEPLOY_HOST}"
|
||||
: "${H5_DEPLOY_PATH:?在 deploy.env 设置 H5_DEPLOY_PATH}"
|
||||
|
||||
echo ">>> build h5"
|
||||
npm run build:h5
|
||||
|
||||
DEST="${H5_DEPLOY_USER}@${H5_DEPLOY_HOST}:${H5_DEPLOY_PATH}/"
|
||||
echo ">>> rsync → $DEST"
|
||||
rsync -avz --delete "${ROOT}/apps/user-h5/dist/" "$DEST"
|
||||
|
||||
echo ">>> done"
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本地小程序 + H5 联调:启动 Vite,并打印微信开发者工具所需地址
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
pick_ip() {
|
||||
local addr=""
|
||||
if command -v ipconfig >/dev/null 2>&1; then
|
||||
addr="$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -z "$addr" ]; then
|
||||
addr="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
||||
fi
|
||||
echo "$addr"
|
||||
}
|
||||
|
||||
IP="$(pick_ip)"
|
||||
IP="${IP:-127.0.0.1}"
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════════════"
|
||||
echo " 愈心魔方 · 本地 H5 联调"
|
||||
echo "══════════════════════════════════════════════════════════"
|
||||
echo " 1. 确认小程序 util/yxgConfig.local.js 中 LOCAL_H5_HOST 为:"
|
||||
echo " ${IP}:5173"
|
||||
echo " 2. digital-psychology 本窗口保持运行(Vite + API 代理 /psy/api → :8080)"
|
||||
echo " 需要接口时另开终端: npm run dev:api 或 npm run deps:up"
|
||||
echo " 3. 微信开发者工具 → 详情 → 本地设置 → 勾选:"
|
||||
echo " 「不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书」"
|
||||
echo " 4. 编译运行小程序(开发模式),打开「愈心魔方」Tab"
|
||||
echo ""
|
||||
echo " 浏览器自测: http://${IP}:5173/psy/?mp=1&sbh=88"
|
||||
echo "══════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
npm run dev:h5
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user