mirror of
https://github.com/go-admin-team/go-admin.git
synced 2026-09-24 19:17:43 +00:00
Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c463f3696d | ||
|
|
60db31e0e8 | ||
|
|
e3e5d3e550 | ||
|
|
f427a42b4f | ||
|
|
1169cb3e87 | ||
|
|
ec10917272 | ||
|
|
b8af16baf0 | ||
|
|
92987cdee3 | ||
|
|
df98ffb5c5 | ||
|
|
acc9378283 | ||
|
|
b4b5bc5b3a | ||
|
|
27ad988fd7 | ||
|
|
effc3a3e69 | ||
|
|
08f789737f | ||
|
|
f57bf5d61d | ||
|
|
143dbf19a2 | ||
|
|
f6bd306d6d | ||
|
|
3beb00143a | ||
|
|
7a52a50964 | ||
|
|
630e13686c | ||
|
|
05661e2f3e | ||
|
|
3625ce851b | ||
|
|
8b312bed1d | ||
|
|
30bcb57f41 | ||
|
|
5bb211afcd | ||
|
|
d102c4b7c1 | ||
|
|
545453c93e | ||
|
|
c3d2a5952b | ||
|
|
d65b21baf6 | ||
|
|
0f31feae6f | ||
|
|
0494a27d6c | ||
|
|
e2b6ddb290 | ||
|
|
28eba92077 | ||
|
|
aa7a92664d | ||
|
|
68ecc5f9a8 | ||
|
|
8341044251 | ||
|
|
8b03400ecc | ||
|
|
8115c3a737 | ||
|
|
c01307202d | ||
|
|
5ecb1e6e4c | ||
|
|
9c299805c1 | ||
|
|
2a900c9876 | ||
|
|
0008b943a3 | ||
|
|
50c74b1f96 | ||
|
|
ae1eef6d4f | ||
|
|
d01cdc040f | ||
|
|
92b9af17b7 | ||
|
|
898e1b023a | ||
|
|
1cd39b80b3 | ||
|
|
a90c67473e | ||
|
|
1a84b8a892 | ||
|
|
656d14cd54 | ||
|
|
9dd271ecab | ||
|
|
4973ee030d | ||
|
|
137bb3ad33 | ||
|
|
03d587db6a | ||
|
|
1f56b956d2 | ||
|
|
37aece9791 | ||
|
|
309b400bc0 | ||
|
|
1b9868b72b | ||
|
|
25344aa572 | ||
|
|
ea9d27cf6d | ||
|
|
0bee8ec46c | ||
|
|
3a5afeb518 | ||
|
|
fc8ba4d615 | ||
|
|
dc20062e5b | ||
|
|
ff430c509b | ||
|
|
d43d7a46dd | ||
|
|
72c496ab93 | ||
|
|
b228152308 | ||
|
|
006756ea40 | ||
|
|
aa539c061f | ||
|
|
74b0ee8776 | ||
|
|
412413c12f | ||
|
|
35d213f339 | ||
|
|
9c68bc25a5 | ||
|
|
9c5d9d16a7 | ||
|
|
46c10f999a | ||
|
|
d12f40c9a0 | ||
|
|
cd363fce3d | ||
|
|
709cebd4a7 | ||
|
|
ba5ef9f79c | ||
|
|
2c50317a98 | ||
|
|
28350a15bb | ||
|
|
c6d3ea5f81 | ||
|
|
7fadb4b585 | ||
|
|
691df82016 | ||
|
|
ea348fa9d1 | ||
|
|
925c6772a6 | ||
|
|
0c60e44aee | ||
|
|
d6e2c02fda | ||
|
|
e98b65cf90 | ||
|
|
bc5411c30c |
@@ -61,7 +61,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: 1.26.5
|
||||
go-version: 1.27.1
|
||||
|
||||
- name: Tidy
|
||||
run: go mod tidy
|
||||
@@ -117,7 +117,41 @@ jobs:
|
||||
|
||||
test -f "$CFG" || { echo "宿主机配置缺失,中止部署"; exit 1; }
|
||||
|
||||
# Old images of this repository are removed here and nowhere else.
|
||||
# Every deployment pulls one tagged with its commit and nothing ever
|
||||
# removed the previous one, so they only accumulated: 68 of them
|
||||
# filled the disk and the next deployment could not pull.
|
||||
#
|
||||
# Three are kept so a release can be re-run by tag by hand.
|
||||
#
|
||||
# Only this repository's images are listed, because the host runs
|
||||
# other services whose images are not this script's business. The
|
||||
# image the live container is on is excluded by id rather than by
|
||||
# position, so it survives even if the listing order is not what
|
||||
# it looks like. With no container to ask, the function returns
|
||||
# rather than running the pipeline on an empty id - which would
|
||||
# also delete nothing, but by way of grep -v matching every line,
|
||||
# which reads like the opposite of what it does. No -f, so an image
|
||||
# any container still holds - including the one kept for rollback -
|
||||
# is refused rather than taken away from it.
|
||||
prune_old_images() {
|
||||
REPO="${IMG%:*}"
|
||||
LIVE=$(sudo docker inspect -f '{{.Image}}' "$NAME" 2>/dev/null | sed 's/^sha256://' | cut -c1-12)
|
||||
[ -n "$LIVE" ] || return 0
|
||||
sudo docker images "$REPO" --format '{{.ID}} {{.Repository}}:{{.Tag}}' \
|
||||
| grep -v "^$LIVE" \
|
||||
| tail -n +3 \
|
||||
| awk '{print $2}' \
|
||||
| xargs -r -n1 sudo docker rmi >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
sudo docker login --username=${{ secrets.DOCKER_USERNAME }} registry.ap-northeast-1.aliyuncs.com --password=${{ secrets.DOCKER_PASSWORD }}
|
||||
# Before the pull, not only after a successful deploy. The pull
|
||||
# is the first thing here that needs space and it is where a full
|
||||
# disk stops this script, so a cleanup that only runs afterwards
|
||||
# never runs on the host that needs it: rerunning the workflow
|
||||
# fails at the same pull, and the disk has to be cleared by hand.
|
||||
prune_old_images
|
||||
sudo docker pull "$IMG" || { echo "拉取镜像失败,中止部署"; exit 1; }
|
||||
|
||||
# 迁移用新镜像跑。失败时线上仍是旧版本配旧 schema,是自洽的;
|
||||
@@ -155,6 +189,10 @@ jobs:
|
||||
|
||||
if [ "$ok" = "1" ]; then
|
||||
sudo docker rm -f "$PREV" >/dev/null 2>&1 || true
|
||||
|
||||
# Again, so the image this deployment replaced falls out of the
|
||||
# window rather than waiting for the next deployment to notice.
|
||||
prune_old_images
|
||||
else
|
||||
echo "健康检查失败,回滚到上一版本"
|
||||
sudo docker logs --tail 40 "$NAME" 2>&1 || true
|
||||
|
||||
@@ -46,6 +46,50 @@ jobs:
|
||||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
|
||||
# The fourth registered driver, and the one that disagrees with the
|
||||
# other three about NULL: its unique index treats two NULLs as equal and
|
||||
# permits one. A migration that builds a unique index over a nullable
|
||||
# column therefore fails here and nowhere else, which is how one shipped
|
||||
# that no SQL Server database could apply at all - not even an empty
|
||||
# one. The password is this container's only credential and the
|
||||
# container lives for the length of one job.
|
||||
sqlserver:
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
env:
|
||||
ACCEPT_EULA: "Y"
|
||||
MSSQL_SA_PASSWORD: GoAdmin_Test1
|
||||
MSSQL_PID: Developer
|
||||
ports:
|
||||
- 1433:1433
|
||||
options: >-
|
||||
--health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P GoAdmin_Test1 -C -Q 'SELECT 1'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
--health-start-period 20s
|
||||
|
||||
# The dialect most installations actually run, and until the
|
||||
# scheduler lease (#915) the only one with no service here. The lease
|
||||
# reads the database's clock, and the first implementation read it as
|
||||
# a timestamp: over go-admin's own `parseTime=True&loc=Local` DSN,
|
||||
# MySQL's UTC_TIMESTAMP comes back relabelled as local time, so on any
|
||||
# host that is not UTC every lease was one zone offset out - and every
|
||||
# assertion that compared the lease only against itself still passed.
|
||||
# The three dialects that were here could not see it.
|
||||
mysql:
|
||||
image: mysql:8
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: GoAdmin_Test1
|
||||
MYSQL_DATABASE: goadmin_test
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -pGoAdmin_Test1"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
--health-start-period 20s
|
||||
|
||||
env:
|
||||
GO_ADMIN_TEST_REDIS_ADDR: 127.0.0.1:6379
|
||||
# The soft-delete conversion drops an index, and gorm's PostgreSQL driver
|
||||
@@ -54,21 +98,41 @@ jobs:
|
||||
# migration that failed on every PostgreSQL database it was pointed at.
|
||||
# See go-admin#919.
|
||||
GO_ADMIN_TEST_POSTGRES_DSN: "host=127.0.0.1 port=5432 user=postgres password=postgres dbname=goadmin_test sslmode=disable"
|
||||
GO_ADMIN_TEST_SQLSERVER_DSN: "sqlserver://sa:GoAdmin_Test1@127.0.0.1:1433?database=goadmin_test"
|
||||
# loc=Local on purpose: it is what config/settings.yml ships and what
|
||||
# made the timezone defect above reachable. A DSN here that quietly
|
||||
# differed from the one installations use would test a configuration
|
||||
# nobody runs.
|
||||
GO_ADMIN_TEST_MYSQL_DSN: "root:GoAdmin_Test1@tcp(127.0.0.1:3306)/goadmin_test?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go 1.26
|
||||
- name: Set up Go 1.27
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version: 1.26.5
|
||||
go-version: 1.27.1
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# SQL Server has no equivalent of POSTGRES_DB, so the database the DSN
|
||||
# names has to be created before the tests run.
|
||||
- name: Create the SQL Server test database
|
||||
run: |
|
||||
docker exec ${{ job.services.sqlserver.id }} /opt/mssql-tools18/bin/sqlcmd \
|
||||
-S localhost -U sa -P GoAdmin_Test1 -C \
|
||||
-Q "IF DB_ID('goadmin_test') IS NULL CREATE DATABASE goadmin_test"
|
||||
|
||||
- name: Get dependencies
|
||||
run: go mod tidy
|
||||
|
||||
# Before the tests rather than beside checksilent at the end: a formatting
|
||||
# miss is a one-command fix, and finding out about it after five minutes of
|
||||
# tests and an end-to-end install is five minutes nobody gets back.
|
||||
- name: Formatting
|
||||
run: make fmt-check
|
||||
|
||||
# go build does not compile _test.go, so building alone never ran a single
|
||||
# test. This is the only workflow that fires on every push and pull request,
|
||||
# which makes it the one place a test gate belongs.
|
||||
@@ -78,6 +142,15 @@ jobs:
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
# A separate module, so none of the steps above see it: the main module's
|
||||
# go.mod, its build and its tests are all unaware of the example
|
||||
# application. This is the only thing that exercises an application being
|
||||
# installed at all - everything below it runs against an injected engine
|
||||
# and a hand-built schema, and none of that can catch an application's
|
||||
# init() reaching one registry and not the other.
|
||||
- name: End-to-end install and uninstall
|
||||
run: make test-e2e
|
||||
|
||||
# Fails the build on the silent-failure classes listed in
|
||||
# tools/checksilent, one of which is the contract boundary: nothing under
|
||||
# common/ may import app/. A boundary that is only written down erodes; this
|
||||
|
||||
@@ -20,7 +20,7 @@ Router → Api → Service → Model
|
||||
|
||||
## 优先使用通用 Action
|
||||
|
||||
单表 CRUD **不要手写 Handler 与 Service**。`common/actions` 提供的五个
|
||||
单表 CRUD **不要手写 Api 与 Service**。`common/actions` 提供的五个
|
||||
Action 已覆盖参数绑定、数据权限过滤、操作人注入、分页与错误响应:
|
||||
|
||||
```go
|
||||
@@ -49,7 +49,7 @@ r := v1.Group("/demo-product").Use(authMiddleware.MiddlewareFunc()).Use(middlewa
|
||||
就地返回会串数据(`app/demo` 的测试锁定了这一点)
|
||||
- 详情/删除 DTO 内嵌 `dto.ObjectById` 即可继承 `Bind` 与 `GetId`,无需重写
|
||||
|
||||
仅当业务超出单表 CRUD(跨表事务、外部调用、复杂校验)时才自行编写 Handler
|
||||
仅当业务超出单表 CRUD(跨表事务、外部调用、复杂校验)时才自行编写 Api
|
||||
与 Service,写法见下。
|
||||
|
||||
## Api 层(仅在通用 Action 不适用时)
|
||||
@@ -167,7 +167,7 @@ sys_menu / sys_menu_api_rule / casbin_rule 四张表如何配齐,用的是幂
|
||||
|
||||
## Swagger
|
||||
|
||||
Handler 必须带完整注解,`go generate` 会据此生成文档:
|
||||
Api 必须带完整注解,`go generate` 会据此生成文档:
|
||||
|
||||
```go
|
||||
// @Summary 岗位列表
|
||||
|
||||
+21
-5
@@ -4,10 +4,26 @@ FROM alpine
|
||||
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories
|
||||
|
||||
RUN apk update --no-cache
|
||||
RUN apk add --update gcc g++ libc6-compat
|
||||
RUN apk add --no-cache ca-certificates
|
||||
RUN apk add --no-cache tzdata
|
||||
# Runtime packages only.
|
||||
#
|
||||
# gcc and g++ used to be installed here and were 273MB of a 381MB image. The
|
||||
# binary this image runs is compiled and linked before the image is built and
|
||||
# arrives as a COPY, so nothing in the container ever invokes a compiler -
|
||||
# there is no toolchain to drive it with either, since Go itself is not
|
||||
# installed.
|
||||
#
|
||||
# That layer was also why a host could not share storage between images. apk
|
||||
# resolves against an index that moves, so the layer digest differed on every
|
||||
# build and no two images shared it: a host keeping one image per deployed
|
||||
# commit stored a private 273MB copy each time. 68 of them filled the disk
|
||||
# and the next deployment could not pull.
|
||||
#
|
||||
# libc6-compat stays. Nothing measured needs it - the binary CI produces is
|
||||
# statically linked, and a container built without libc6-compat resolves a
|
||||
# hostname and opens a database connection exactly as one built with it - but
|
||||
# it is half a megabyte and it covers a ./main that was linked dynamically,
|
||||
# which this Dockerfile has no way to check.
|
||||
RUN apk add --no-cache ca-certificates tzdata libc6-compat
|
||||
ENV TZ Asia/Shanghai
|
||||
|
||||
COPY ./main /main
|
||||
@@ -15,4 +31,4 @@ COPY ./config/settings.demo.yml /config/settings.yml
|
||||
COPY ./go-admin-db.db /go-admin-db.db
|
||||
EXPOSE 8000
|
||||
RUN chmod +x /main
|
||||
CMD ["/main","server","-c", "/config/settings.yml"]
|
||||
CMD ["/main","server","-c", "/config/settings.yml"]
|
||||
|
||||
@@ -54,6 +54,18 @@ stop:
|
||||
test:
|
||||
go test -race -cover ./...
|
||||
|
||||
# The end-to-end install, which `test` above cannot reach: test/e2e-apporder
|
||||
# is its own module, so `./...` in this one does not include it. It builds a
|
||||
# go-admin binary with the example application linked in and drives
|
||||
# `migrate install` / `migrate uninstall` against a real database.
|
||||
#
|
||||
# Its own target rather than a line in the CI workflow, so the one thing in
|
||||
# the build that exercises installing an application is also the one thing
|
||||
# somebody can run before pushing.
|
||||
.PHONY: test-e2e
|
||||
test-e2e:
|
||||
cd test/e2e-apporder && go test ./... -count=1
|
||||
|
||||
# Reports the failures that do not announce themselves - see
|
||||
# tools/checksilent. Exits non-zero on an ERROR; the one WARN-level check
|
||||
# prints and does not fail the build.
|
||||
@@ -68,6 +80,23 @@ else
|
||||
go run ./tools/checksilent
|
||||
endif
|
||||
|
||||
# gofmt as a gate, not a rewrite. CI cannot commit, and a target that quietly
|
||||
# reformats hides what it touched, so this reports and fails instead. `gofmt -l`
|
||||
# prints the files it would rewrite and nothing at all when there are none, so
|
||||
# that list is both the failure message and the instructions for fixing it.
|
||||
#
|
||||
# The tree reached zero unformatted files once; without something holding it
|
||||
# there it drifts back, which is how the previous batch grew to 26 files -
|
||||
# mostly a missing newline at the end of the file, which no reviewer notices.
|
||||
.PHONY: fmt-check
|
||||
fmt-check:
|
||||
@unformatted=$$(gofmt -l .); \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
echo "gofmt would rewrite these files. Run 'gofmt -w .' and commit the result:"; \
|
||||
echo "$$unformatted"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
#.PHONY: docker
|
||||
#docker:
|
||||
# docker build . -t go-admin:latest
|
||||
|
||||
+1
-7
@@ -106,7 +106,7 @@ antd 体验(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admi
|
||||
|
||||
### 环境要求
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.1
|
||||
|
||||
node版本: v22+(推荐 v24 LTS)
|
||||
|
||||
@@ -277,15 +277,11 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
@@ -299,8 +295,6 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
|
||||
|
||||
+1
-7
@@ -106,7 +106,7 @@ antd デモ(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admi
|
||||
|
||||
### 動作要件
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.1
|
||||
|
||||
node バージョン: v22 以上(v24 LTS 推奨)
|
||||
|
||||
@@ -277,15 +277,11 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
@@ -299,8 +295,6 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
|
||||
|
||||
@@ -104,7 +104,7 @@ At the same time, a series of tutorials including videos and documents are provi
|
||||
|
||||
### Environmental requirements
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.1
|
||||
|
||||
nodejs: v22+ (v24 LTS recommended)
|
||||
|
||||
@@ -263,15 +263,11 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
@@ -285,8 +281,6 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
|
||||
|
||||
+1
-7
@@ -106,7 +106,7 @@ antd 體驗(go-admin-pro):[https://antd.go-admin.pro](https://antd.go-admi
|
||||
|
||||
### 環境需求
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.1
|
||||
|
||||
node 版本: v22+(建議 v24 LTS)
|
||||
|
||||
@@ -277,15 +277,11 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/ninstein" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/580303?v=4&h=60&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/kikiyou" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17959053?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/horizonzy" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/22524871?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Cassuis" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/48005724?s=64&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/hqcchina" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/5179057?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/nodece" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/16235121?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stephenzhang0713" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/18169290?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/zhouxixi-dev" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/100399679?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Jalins" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/31172582?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wkf928592" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6063351?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxxiong6" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/6983441?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/Silicon-He" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/52478309?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/GizmoOAO" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20385106?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/bestgopher" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/36840497?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/wxb1207" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/20775558?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
@@ -299,8 +295,6 @@ pnpm dev
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/infnan" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/38274826?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/d1y" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45585937?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/qlijin" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/515900?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/logtous
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/88697234?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/stepway
|
||||
" ><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/9927079?s=60&v=4&w=60&fit=cover&mask=circle&maxage=7d" /></a></span>
|
||||
<span style="margin: 0 5px;" ><a href="https://github.com/NaturalGao
|
||||
|
||||
@@ -3,9 +3,9 @@ package apis
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service"
|
||||
@@ -145,4 +145,4 @@ func (e SysApi) DeleteSysApi(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ package apis
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
@@ -216,5 +216,5 @@ func (e SysDictData) GetAll(c *gin.Context) {
|
||||
l = append(l, d)
|
||||
}
|
||||
|
||||
e.OK(l,"查询成功")
|
||||
e.OK(l, "查询成功")
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"go-admin/app/admin/models"
|
||||
|
||||
"go-admin/app/admin/service"
|
||||
@@ -31,7 +31,7 @@ type SysDictType struct {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) GetPage(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeGetPageReq{}
|
||||
req := dto.SysDictTypeGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
@@ -62,7 +62,7 @@ func (e SysDictType) GetPage(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Get(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeGetReq{}
|
||||
req := dto.SysDictTypeGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
@@ -82,7 +82,7 @@ func (e SysDictType) Get(c *gin.Context) {
|
||||
e.OK(object, "查询成功")
|
||||
}
|
||||
|
||||
//Insert 字典类型创建
|
||||
// Insert 字典类型创建
|
||||
// @Summary 添加字典类型
|
||||
// @Description 获取JSON
|
||||
// @Tags 字典类型
|
||||
@@ -94,7 +94,7 @@ func (e SysDictType) Get(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Insert(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeInsertReq{}
|
||||
req := dto.SysDictTypeInsertReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
@@ -109,7 +109,7 @@ func (e SysDictType) Insert(c *gin.Context) {
|
||||
err = s.Insert(&req)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500, err,fmt.Sprintf(" 创建字典类型失败,详情:%s", err.Error()))
|
||||
e.Error(500, err, fmt.Sprintf(" 创建字典类型失败,详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "创建成功")
|
||||
@@ -127,7 +127,7 @@ func (e SysDictType) Insert(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Update(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeUpdateReq{}
|
||||
req := dto.SysDictTypeUpdateReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
@@ -157,7 +157,7 @@ func (e SysDictType) Update(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) Delete(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeDeleteReq{}
|
||||
req := dto.SysDictTypeDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
@@ -189,7 +189,7 @@ func (e SysDictType) Delete(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysDictType) GetAll(c *gin.Context) {
|
||||
s := service.SysDictType{}
|
||||
req :=dto.SysDictTypeGetPageReq{}
|
||||
req := dto.SysDictTypeGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
@@ -207,4 +207,4 @@ func (e SysDictType) GetAll(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
e.OK(list, "查询成功")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ type SysLoginLog struct {
|
||||
// @Security Bearer
|
||||
func (e SysLoginLog) GetPage(c *gin.Context) {
|
||||
s := service.SysLoginLog{}
|
||||
req :=dto.SysLoginLogGetPageReq{}
|
||||
req := dto.SysLoginLogGetPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
@@ -60,7 +60,7 @@ func (e SysLoginLog) GetPage(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysLoginLog) Get(c *gin.Context) {
|
||||
s := service.SysLoginLog{}
|
||||
req :=dto.SysLoginLogGetReq{}
|
||||
req := dto.SysLoginLogGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req).
|
||||
@@ -90,7 +90,7 @@ func (e SysLoginLog) Get(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysLoginLog) Delete(c *gin.Context) {
|
||||
s := service.SysLoginLog{}
|
||||
req :=dto.SysLoginLogDeleteReq{}
|
||||
req := dto.SysLoginLogDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
@@ -107,4 +107,4 @@ func (e SysLoginLog) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func (e SysOperaLog) GetPage(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysOperaLog) Get(c *gin.Context) {
|
||||
s := new(service.SysOperaLog)
|
||||
req :=dto.SysOperaLogGetReq{}
|
||||
req := dto.SysOperaLogGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
@@ -96,7 +96,7 @@ func (e SysOperaLog) Get(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysOperaLog) Delete(c *gin.Context) {
|
||||
s := new(service.SysOperaLog)
|
||||
req :=dto.SysOperaLogDeleteReq{}
|
||||
req := dto.SysOperaLogDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
@@ -111,7 +111,7 @@ func (e SysOperaLog) Delete(c *gin.Context) {
|
||||
err = s.Remove(&req)
|
||||
if err != nil {
|
||||
e.Logger.Error(err)
|
||||
e.Error(500,err, fmt.Sprintf("删除失败!错误详情:%s", err.Error()))
|
||||
e.Error(500, err, fmt.Sprintf("删除失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
|
||||
@@ -2,12 +2,12 @@ package apis
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/jwtauth/user"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/service"
|
||||
@@ -31,7 +31,7 @@ type SysPost struct {
|
||||
// @Security Bearer
|
||||
func (e SysPost) GetPage(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostPageReq{}
|
||||
req := dto.SysPostPageReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.Form).
|
||||
@@ -65,7 +65,7 @@ func (e SysPost) GetPage(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysPost) Get(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostGetReq{}
|
||||
req := dto.SysPostGetReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, nil).
|
||||
@@ -99,7 +99,7 @@ func (e SysPost) Get(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysPost) Insert(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostInsertReq{}
|
||||
req := dto.SysPostInsertReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
@@ -131,7 +131,7 @@ func (e SysPost) Insert(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysPost) Update(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostUpdateReq{}
|
||||
req := dto.SysPostUpdateReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON, nil).
|
||||
@@ -163,7 +163,7 @@ func (e SysPost) Update(c *gin.Context) {
|
||||
// @Security Bearer
|
||||
func (e SysPost) Delete(c *gin.Context) {
|
||||
s := service.SysPost{}
|
||||
req :=dto.SysPostDeleteReq{}
|
||||
req := dto.SysPostDeleteReq{}
|
||||
err := e.MakeContext(c).
|
||||
MakeOrm().
|
||||
Bind(&req, binding.JSON).
|
||||
@@ -181,4 +181,4 @@ func (e SysPost) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
e.OK(req.GetId(), "删除成功")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go-admin/common/models"
|
||||
)
|
||||
|
||||
// The values sys_app.status takes.
|
||||
//
|
||||
// Three states rather than a single "installed", because an install that
|
||||
// stopped partway has to be an observable row rather than the absence of one:
|
||||
// the versions an app installs are separate migration files, and on MySQL a
|
||||
// DDL statement commits the transaction around it - taking an outer
|
||||
// transaction and every savepoint under it with it - so they cannot be
|
||||
// wrapped in one.
|
||||
//
|
||||
// AppInstalling is also what a row reads as after the process was killed
|
||||
// mid-install, which is why it is not treated as "installed" by anything.
|
||||
const (
|
||||
AppInstalling = 1
|
||||
AppInstalled = 2
|
||||
AppFailed = 3
|
||||
)
|
||||
|
||||
// SysApp is the sys_app row model: one row per installed application (PRD
|
||||
// 008 F2). It deliberately does not embed models.ModelTime - see the design
|
||||
// doc (docs-prd/008-应用清单与安装器/数据库变更.md) §1.1 for why an
|
||||
// installed-app registry does not need the millisecond soft-delete marker
|
||||
// every other sys_* table follows. Uninstalling an app deletes its row
|
||||
// outright; a later reinstall creates a fresh one.
|
||||
type SysApp struct {
|
||||
models.Model // Id int, primary key, autoincrement
|
||||
|
||||
// AppCode is the app.Manifest.Code / migration.ForApp / seed.SeedMenus
|
||||
// identity, already lower-cased by migration.NormalizeAppCode before
|
||||
// anything reaches this table. Unique: row existence alone answers G2
|
||||
// ("is app X installed").
|
||||
AppCode string `json:"appCode" gorm:"type:varchar(64);not null;uniqueIndex:uk_sys_app_app_code;comment:app code"`
|
||||
|
||||
Name string `json:"name" gorm:"size:128;not null;comment:display name, from Manifest.Name"`
|
||||
// Version is the version this row currently reflects - attempted or
|
||||
// confirmed, disambiguated by Status. It does not drive which
|
||||
// migrations run next; sys_migration's per-version rows do that (see
|
||||
// design doc §1.5's resume flow). This field is descriptive, refreshed
|
||||
// from the manifest on every install/upgrade/resume attempt.
|
||||
Version string `json:"version" gorm:"size:32;not null;comment:version this row currently reflects, see Status"`
|
||||
Description string `json:"description" gorm:"size:255;not null;default:'';comment:from Manifest.Description"`
|
||||
Author string `json:"author" gorm:"size:128;not null;default:'';comment:from Manifest.Author"`
|
||||
|
||||
// Requires is a comma-separated list of app codes this app declared as
|
||||
// dependencies (Manifest.Requires). Stored as plain VARCHAR CSV, not
|
||||
// JSON - see design doc §1.3 for why. F8 (P1) is what validates and
|
||||
// orders on this; this batch only stores what the manifest declared.
|
||||
Requires string `json:"requires" gorm:"size:255;not null;default:'';comment:declared dependency app codes, comma separated"`
|
||||
|
||||
// Pricing/License are reserved passthrough fields (PRD 003; PRD 008
|
||||
// open question 1). This batch stores whatever the manifest carries and
|
||||
// does not interpret either one.
|
||||
Pricing string `json:"pricing" gorm:"size:64;not null;default:'';comment:reserved, not interpreted by this batch"`
|
||||
License string `json:"license" gorm:"size:64;not null;default:'';comment:reserved, not interpreted by this batch"`
|
||||
|
||||
// Status: 1=installing 2=installed 3=failed. Three states, not a
|
||||
// single "1=installed", because a partial, stuck install has to be an
|
||||
// observable row rather than "the row doesn't exist yet" - see design
|
||||
// doc §1.5 for why cross-migration-file atomicity is not available on
|
||||
// MySQL (implicit commit on DDL).
|
||||
Status int `json:"status" gorm:"size:4;not null;default:1;comment:1=installing 2=installed 3=failed"`
|
||||
|
||||
// FailedVersion and LastError are DIAGNOSTIC TEXT ONLY - what a human
|
||||
// looking at this row is told about the last failure, nothing more. No
|
||||
// code anywhere may read either one to decide what to do next.
|
||||
//
|
||||
// The question "where should a resume pick up" has exactly one
|
||||
// authoritative answer, and it is not these two columns: subtract
|
||||
// sys_migration's applied rows for this app_code from what the app's
|
||||
// own compiled-in code has registered (migration.Snapshot()/ForApp -
|
||||
// the same set F7's `migrate status` already walks). That answer can
|
||||
// never go stale, because it is not stored anywhere to go stale - it is
|
||||
// recomputed from sys_migration every time it is asked. FailedVersion
|
||||
// is a snapshot of what that computation returned at the moment of
|
||||
// failure, kept only so an operator does not have to go find the
|
||||
// process's logs; if it and a fresh recomputation from sys_migration
|
||||
// ever disagree, sys_migration is right and this column is stale, by
|
||||
// definition, and nothing should ever notice or care except a human
|
||||
// reading the row.
|
||||
FailedVersion string `json:"failedVersion" gorm:"size:64;not null;default:'';comment:diagnostic snapshot only, not a judgment basis; meaningful only when status=3"`
|
||||
LastError string `json:"lastError" gorm:"size:255;not null;default:'';comment:diagnostic text only, not a judgment basis; meaningful only when status=3"`
|
||||
|
||||
// InstalledAt is when this app first reached status=installed - set
|
||||
// once, never moved by a later upgrade (see design doc §1.4). Nullable,
|
||||
// unlike every other column here: a row can exist before it has a
|
||||
// value (a fresh install starts at status=installing). This is not the
|
||||
// deleted_at problem 1786700003000_soft_delete_marker.go fixed - that
|
||||
// column sat inside a unique index, where NULL <> NULL let two live
|
||||
// rows coexist under the same key. InstalledAt is in no index at all,
|
||||
// so nullability here opens no such hole.
|
||||
InstalledAt *time.Time `json:"installedAt" gorm:"comment:first successful install time; null until status first reaches installed"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"comment:last updated time"`
|
||||
|
||||
models.ControlBy // CreateBy/UpdateBy: which operator triggered the attempt
|
||||
}
|
||||
|
||||
func (*SysApp) TableName() string {
|
||||
return "sys_app"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// SysAppCasbinGrant is a ledger of casbin_rule rows an app install created,
|
||||
// keyed by the exact natural key casbin_rule itself is unique on. It exists
|
||||
// because casbin_rule is not a table this project owns (see design doc
|
||||
// docs-prd/008-应用清单与安装器/数据库变更.md §2.2): we cannot add an
|
||||
// app_code column to it without that column being silently zeroed the first
|
||||
// time anything calls the gorm-adapter's SavePolicy/SavePolicyCtx. Recording
|
||||
// the natural key here, instead of a foreign key into casbin_rule, is also
|
||||
// what survives SysRole.Update's RemoveFilteredPolicy+re-add cycle for a
|
||||
// role's policies (app/admin/service/sys_role.go): that cycle replaces the
|
||||
// underlying row (a new auto-increment ID) but reproduces the same
|
||||
// (ptype,v0,v1,v2) tuple from the same sys_menu/sys_api data, so a
|
||||
// natural-key match here still finds it. What it does not survive is the
|
||||
// role being renamed, or the tuple being rebuilt from a completely different
|
||||
// source (a future SavePolicy call from outside this seeder) - in both cases
|
||||
// the match legitimately fails, and business rule 3 says the uninstaller
|
||||
// should report and skip, not delete something else that happens to look
|
||||
// the same.
|
||||
type SysAppCasbinGrant struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
|
||||
AppCode string `json:"appCode" gorm:"type:varchar(64);not null;index:idx_sys_app_casbin_grant_app_code;comment:app code that created this grant"`
|
||||
|
||||
// Column widths mirror gorm-adapter's own CasbinRule struct exactly, so
|
||||
// a value that fits into casbin_rule always fits here, and the unique
|
||||
// index below matches the one createTable() puts on casbin_rule itself.
|
||||
Ptype string `json:"ptype" gorm:"size:100;not null;uniqueIndex:uk_sys_app_casbin_grant_rule;comment:casbin ptype, 'p' today"`
|
||||
V0 string `json:"v0" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:role_key at grant time"`
|
||||
V1 string `json:"v1" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:api path"`
|
||||
V2 string `json:"v2" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:http method"`
|
||||
V3 string `json:"v3" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:unused today"`
|
||||
V4 string `json:"v4" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:unused today"`
|
||||
V5 string `json:"v5" gorm:"size:100;not null;default:'';uniqueIndex:uk_sys_app_casbin_grant_rule;comment:unused today"`
|
||||
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"comment:when this grant was recorded"`
|
||||
}
|
||||
|
||||
func (*SysAppCasbinGrant) TableName() string {
|
||||
return "sys_app_casbin_grant"
|
||||
}
|
||||
@@ -32,6 +32,30 @@ type SysMenu struct {
|
||||
// AutoMigrate adding this column to an existing table leaves every
|
||||
// pre-existing row reading back as "" rather than NULL.
|
||||
AppCode string `json:"appCode" gorm:"type:varchar(64);not null;default:'';index:idx_sys_menu_app_code;comment:AppCode"`
|
||||
// SeedCode is the raw seed.MenuSpec.Code this row was created from, kept
|
||||
// so seedMenuTree can ask "did I already write this node" without
|
||||
// relying on MenuName's PascalCase concatenation, which is not
|
||||
// injective (see design doc §1.6). Nullable, unlike AppCode: every row
|
||||
// seed.SeedMenus writes sets a real value, but every pre-existing row -
|
||||
// the host's own hand-placed menus, and every app-seeded row written
|
||||
// before this column existed - has none, and there is no way to
|
||||
// backfill one that means anything. NULL is what lets an unbounded
|
||||
// number of those coexist under the same app_code without tripping the
|
||||
// unique index below: the database never treats two NULLs as equal, so
|
||||
// only rows that do carry a real code participate in the uniqueness
|
||||
// check at all.
|
||||
// uk_sys_menu_app_seed_code_del is created by the migration, not from
|
||||
// this tag, and deliberately: it covers (app_code, seed_code,
|
||||
// deleted_at), and this struct cannot say so. A named uniqueIndex tag
|
||||
// puts every field carrying that name into one index, and deleted_at
|
||||
// comes from the shared ModelTime embed, which no single model can add a
|
||||
// tag to. Naming it here anyway declared a unique index on seed_code
|
||||
// alone under the same name - stricter than the real one, forbidding two
|
||||
// applications from both having a "dir" node - and AutoMigrate on this
|
||||
// model would have created that one first, after which the migration's
|
||||
// HasIndex guard finds the name taken and leaves the wrong index in
|
||||
// place.
|
||||
SeedCode *string `json:"seedCode" gorm:"size:64;comment:raw MenuSpec.Code, null for rows not written through SeedMenus"`
|
||||
models.ControlBy
|
||||
models.ModelTime
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
|
||||
// uk_sys_menu_app_seed_code_del covers (app_code, seed_code, deleted_at) and
|
||||
// is created by 1786700008000, not from a struct tag. It cannot come from a
|
||||
// tag: a named uniqueIndex collects every field carrying that name, and
|
||||
// deleted_at lives in the shared ModelTime embed that no single model can tag.
|
||||
//
|
||||
// Naming it on SeedCode alone anyway produced a unique index on seed_code by
|
||||
// itself under the same name - stricter than the real one - and AutoMigrate
|
||||
// here would create that one, after which the migration's HasIndex guard
|
||||
// finds the name taken and leaves the wrong index in place. Nothing in
|
||||
// production AutoMigrates this model (the initial table migration uses a
|
||||
// frozen snapshot that has neither column), which is why this never showed up
|
||||
// as a broken database; it showed up the first time a test built the schema
|
||||
// from the live model and seeded two applications.
|
||||
func TestSysMenuDeclaresNoSeedCodeIndexOfItsOwn(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&SysMenu{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
if db.Migrator().HasIndex(&SysMenu{}, "uk_sys_menu_app_seed_code_del") {
|
||||
t.Error("AutoMigrate created uk_sys_menu_app_seed_code_del from a tag; " +
|
||||
"the migration's HasIndex guard will now skip the composite index it should create")
|
||||
}
|
||||
|
||||
// Two applications, the same seed code. The real index allows it because
|
||||
// app_code is part of the key; an index on seed_code alone does not.
|
||||
for _, app := range []string{"order", "crm"} {
|
||||
row := SysMenu{MenuName: app + "Dir", AppCode: app, SeedCode: ptr("dir")}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("%s could not use the seed code \"dir\": %v", app, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,4 +29,4 @@ func registerSysDeptRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
r1.GET("/deptTree", api.Get2Tree)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@ func registerSysLoginLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
|
||||
r.GET("/:id", api.Get)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,4 +30,4 @@ func registerSysMenuRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
//r1.GET("/menuids", api.GetMenuIDS)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,4 @@ func registerSysOperaLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
|
||||
r.GET("/:id", api.Get)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,4 @@ func registerSyPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
|
||||
r.PUT("/:id", api.Update)
|
||||
r.DELETE("", api.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,4 +36,4 @@ func registerSysUserRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
{
|
||||
v1auth.GET("/getinfo", api.GetInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@ import (
|
||||
|
||||
// SysDeptGetPageReq 列表或者搜索使用结构体
|
||||
type SysDeptGetPageReq struct {
|
||||
DeptId int `form:"deptId" search:"type:exact;column:dept_id;table:sys_dept" comment:"id"` //id
|
||||
ParentId int `form:"parentId" search:"type:exact;column:parent_id;table:sys_dept" comment:"上级部门"` //上级部门
|
||||
DeptPath string `form:"deptPath" search:"type:exact;column:dept_path;table:sys_dept" comment:""` //路径
|
||||
DeptName string `form:"deptName" search:"type:exact;column:dept_name;table:sys_dept" comment:"部门名称"` //部门名称
|
||||
Sort int `form:"sort" search:"type:exact;column:sort;table:sys_dept" comment:"排序"` //排序
|
||||
Leader string `form:"leader" search:"type:exact;column:leader;table:sys_dept" comment:"负责人"` //负责人
|
||||
Phone string `form:"phone" search:"type:exact;column:phone;table:sys_dept" comment:"手机"` //手机
|
||||
Email string `form:"email" search:"type:exact;column:email;table:sys_dept" comment:"邮箱"` //邮箱
|
||||
Status string `form:"status" search:"type:exact;column:status;table:sys_dept" comment:"状态"` //状态
|
||||
DeptId int `form:"deptId" search:"type:exact;column:dept_id;table:sys_dept" comment:"id"` //id
|
||||
ParentId int `form:"parentId" search:"type:exact;column:parent_id;table:sys_dept" comment:"上级部门"` //上级部门
|
||||
DeptPath string `form:"deptPath" search:"type:exact;column:dept_path;table:sys_dept" comment:""` //路径
|
||||
DeptName string `form:"deptName" search:"type:exact;column:dept_name;table:sys_dept" comment:"部门名称"` //部门名称
|
||||
Sort int `form:"sort" search:"type:exact;column:sort;table:sys_dept" comment:"排序"` //排序
|
||||
Leader string `form:"leader" search:"type:exact;column:leader;table:sys_dept" comment:"负责人"` //负责人
|
||||
Phone string `form:"phone" search:"type:exact;column:phone;table:sys_dept" comment:"手机"` //手机
|
||||
Email string `form:"email" search:"type:exact;column:email;table:sys_dept" comment:"邮箱"` //邮箱
|
||||
Status string `form:"status" search:"type:exact;column:status;table:sys_dept" comment:"状态"` //状态
|
||||
}
|
||||
|
||||
func (m *SysDeptGetPageReq) GetNeedSearch() interface{} {
|
||||
|
||||
@@ -54,4 +54,4 @@ type SysLoginLogDeleteReq struct {
|
||||
|
||||
func (s *SysLoginLogDeleteReq) GetId() interface{} {
|
||||
return s.Ids
|
||||
}
|
||||
}
|
||||
|
||||
+339
-26
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -76,7 +77,7 @@ func (adminSeeder) SeedMenus(tx *gorm.DB, appCode string, menus []seed.MenuSpec,
|
||||
if len(menuIDs) == 0 && len(apiRows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return grantToAdminRole(tx, menuIDs, apiRows)
|
||||
return grantToAdminRole(tx, appCode, menuIDs, apiRows)
|
||||
}
|
||||
|
||||
// seedApis writes one sys_api row per ApiSpec and returns them keyed by
|
||||
@@ -90,6 +91,23 @@ func (adminSeeder) SeedMenus(tx *gorm.DB, appCode string, menus []seed.MenuSpec,
|
||||
// application's ids in the module cache. Never accepting a caller-chosen id
|
||||
// here removes the collision this Seeder has no way to detect instead of
|
||||
// trying to detect it after the fact.
|
||||
//
|
||||
// The natural key is (app_code, path, action) - the same three columns
|
||||
// 1786700002000_remove_refresh_token_api.go already used to identify a
|
||||
// single API by hand, and the ones 1786700008000_seed_natural_keys.go put a
|
||||
// unique index on. Before inserting, this looks for a live row (deleted_at
|
||||
// = 0, applied automatically by the soft-delete plugin on every query
|
||||
// against models.SysApi) already holding that key and reuses it instead of
|
||||
// inserting a second one - see the design doc §1.6: a migration retried
|
||||
// after a partial failure previously re-ran this as a bare tx.Create and
|
||||
// produced duplicate rows on the demo site.
|
||||
//
|
||||
// Unlike seedMenuTree's reuse branch, this one has nothing left to repair
|
||||
// after finding an existing row: models.SysApi carries no association
|
||||
// (nothing like SysMenu's many2many SysApi field) and this function writes
|
||||
// nothing beyond the row itself - no second statement comparable to
|
||||
// seedMenuTree's paths UPDATE follows tx.Create below. An interrupted retry
|
||||
// can therefore only ever find this row complete or not find it at all.
|
||||
func seedApis(tx *gorm.DB, appCode string, apis []seed.ApiSpec) (map[string]models.SysApi, error) {
|
||||
seen := make(map[string]bool, len(apis))
|
||||
rows := make(map[string]models.SysApi, len(apis))
|
||||
@@ -102,6 +120,19 @@ func seedApis(tx *gorm.DB, appCode string, apis []seed.ApiSpec) (map[string]mode
|
||||
}
|
||||
seen[a.Code] = true
|
||||
|
||||
var existing models.SysApi
|
||||
err := tx.Where("app_code = ? AND path = ? AND action = ?", appCode, a.Path, a.Method).
|
||||
First(&existing).Error
|
||||
switch {
|
||||
case err == nil:
|
||||
rows[a.Code] = existing
|
||||
continue
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
// Not seen yet; fall through to insert it.
|
||||
default:
|
||||
return nil, fmt.Errorf("api %q: checking for an existing row: %w", a.Code, err)
|
||||
}
|
||||
|
||||
row := models.SysApi{
|
||||
Handle: a.Handle,
|
||||
Title: a.Title,
|
||||
@@ -153,6 +184,12 @@ func seedMenuTree(tx *gorm.DB, appCode string, specs []seed.MenuSpec, apiRows ma
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolved before the idempotency check below, whether or not
|
||||
// this spec's own row turns out to already exist: repairing an
|
||||
// existing-but-incomplete row's paths needs the parent's
|
||||
// already-resolved Paths exactly as much as creating a fresh
|
||||
// row does (see repairExistingMenu), so both have to wait for
|
||||
// it the same way.
|
||||
var parentRow models.SysMenu
|
||||
if s.Parent != "" {
|
||||
parent, ok := created[s.Parent]
|
||||
@@ -165,24 +202,42 @@ func seedMenuTree(tx *gorm.DB, appCode string, specs []seed.MenuSpec, apiRows ma
|
||||
parentRow = parent
|
||||
}
|
||||
|
||||
row := models.SysMenu{
|
||||
MenuName: menuName(appCode, s.Code),
|
||||
Title: s.Title,
|
||||
Icon: s.Icon,
|
||||
Path: s.Path,
|
||||
MenuType: s.Kind,
|
||||
Permission: s.Permission,
|
||||
ParentId: parentRow.MenuId,
|
||||
Component: s.Component,
|
||||
Sort: s.Sort,
|
||||
// Visible "0" is shown, not hidden - the same defaults
|
||||
// 1786700001000_demo_menu.go seeds its own menu with. A
|
||||
// freshly installed application's menu should not need an
|
||||
// administrator to first find and unhide it.
|
||||
Visible: "0",
|
||||
IsFrame: "1",
|
||||
AppCode: appCode,
|
||||
// Idempotency check: does this node already have a row, from
|
||||
// an earlier, possibly-interrupted attempt? The natural key is
|
||||
// (app_code, seed_code) - menu_name's PascalCase concatenation
|
||||
// is not injective and cannot be used for this (see menuName's
|
||||
// doc comment and the design doc §1.6). Only a live row counts;
|
||||
// the soft-delete plugin scopes deleted_at = 0 automatically on
|
||||
// every query against models.SysMenu.
|
||||
var existing models.SysMenu
|
||||
found := false
|
||||
err := tx.Where("app_code = ? AND seed_code = ?", appCode, s.Code).First(&existing).Error
|
||||
switch {
|
||||
case err == nil:
|
||||
found = true
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
// Nothing under the natural key. It may still be here from
|
||||
// before seed_code existed, under the name that identified
|
||||
// it then.
|
||||
existing, found, err = adoptLegacyMenu(tx, appCode, s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q: %w", s.Code, err)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%q: checking for an existing row: %w", s.Code, err)
|
||||
}
|
||||
if found {
|
||||
row, err := repairExistingMenu(tx, existing, appCode, s, parentRow, apiRows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q: repairing an existing row: %w", s.Code, err)
|
||||
}
|
||||
created[s.Code] = row
|
||||
ids = append(ids, row.MenuId)
|
||||
progressed = true
|
||||
continue
|
||||
}
|
||||
|
||||
row := menuRowFor(appCode, s, parentRow)
|
||||
for _, code := range s.ApiCodes {
|
||||
api, ok := apiRows[code]
|
||||
if !ok {
|
||||
@@ -205,11 +260,7 @@ func seedMenuTree(tx *gorm.DB, appCode string, specs []seed.MenuSpec, apiRows ma
|
||||
// two-step create-then-update 1786700001000_demo_menu.go's
|
||||
// hand-assigned ids let it do in one literal, sequenced here
|
||||
// instead.
|
||||
if s.Parent == "" {
|
||||
row.Paths = "/0/" + strconv.Itoa(row.MenuId)
|
||||
} else {
|
||||
row.Paths = parentRow.Paths + "/" + strconv.Itoa(row.MenuId)
|
||||
}
|
||||
row.Paths = expectedPaths(row.MenuId, s.Parent, parentRow)
|
||||
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", row.MenuId).
|
||||
Update("paths", row.Paths).Error; err != nil {
|
||||
return nil, fmt.Errorf("%q: writing paths: %w", s.Code, err)
|
||||
@@ -226,6 +277,103 @@ func seedMenuTree(tx *gorm.DB, appCode string, specs []seed.MenuSpec, apiRows ma
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// expectedPaths is the materialized path a fresh insert of menuID under
|
||||
// parent (or at the root, if parent is "") computes - factored out so
|
||||
// repairExistingMenu can ask the same question about a row it did not just
|
||||
// create.
|
||||
func expectedPaths(menuID int, parent string, parentRow models.SysMenu) string {
|
||||
if parent == "" {
|
||||
return "/0/" + strconv.Itoa(menuID)
|
||||
}
|
||||
return parentRow.Paths + "/" + strconv.Itoa(menuID)
|
||||
}
|
||||
|
||||
// repairExistingMenu brings a row seedMenuTree's idempotency check found up
|
||||
// to what a fresh insert of the same spec would have produced.
|
||||
//
|
||||
// A row can be found and still be incomplete: tx.Create's own association
|
||||
// write (the sys_menu_api_rule bindings from row.SysApi) and the paths
|
||||
// UPDATE that follows it are each their own statement, and design doc §1.5
|
||||
// establishes that nothing after the first DDL in a migration function can
|
||||
// be rolled back together - a process interrupted between the row insert
|
||||
// and either of those two steps leaves exactly this row: present, findable
|
||||
// by its natural key, but missing what makes it a working menu entry. A
|
||||
// retry that only checked "does the row exist" and stopped there would
|
||||
// report success while the sys_menu_api_rule binding stays missing (the
|
||||
// api is granted to no one) or paths stays empty (a materialized-path
|
||||
// break that orphans the rest of the subtree from the root) - as silent as
|
||||
// the duplicate-row defect the idempotency check itself was written to
|
||||
// close.
|
||||
//
|
||||
// Both checks are read-before-write, so a row that is already complete -
|
||||
// the ordinary case on every retry after the first successful one - causes
|
||||
// no writes at all: existing.Paths already equals what expectedPaths
|
||||
// computes, and the sys_menu_api_rule INSERT is itself guarded by
|
||||
// WHERE NOT EXISTS, the same idempotent-insert shape grantToAdminRole
|
||||
// already uses for sys_role_menu/casbin_rule. Never DELETEs an existing
|
||||
// binding to rebuild it - that is the FullSaveAssociations mistake
|
||||
// sys_role.go's SysRole.Update makes for sys_role_menu/casbin_rule
|
||||
// (app/admin/service/sys_role.go:148-153), the exact pattern this design
|
||||
// went out of its way to avoid for the tables that do use it.
|
||||
//
|
||||
// Insert-only cuts both ways, deliberately. A binding an administrator
|
||||
// added by hand through the menu management UI, for an api never in
|
||||
// s.ApiCodes at all, is never touched by this loop and survives every
|
||||
// later retry (TestSeedMenusPreservesAHandAddedBinding is the reproduction
|
||||
// case for the opposite mistake: delete-then-reinsert wipes it silently,
|
||||
// the same shape as sys_role_menu/casbin_rule getting zeroed by a role
|
||||
// edit, just with this code as the actor instead of the victim). The
|
||||
// converse case - a MenuSpec that used to list an ApiCode and no longer
|
||||
// does - is not handled here either, and that half is intentional rather
|
||||
// than an oversight: this loop only ever adds rows for codes the *current*
|
||||
// call's ApiCodes names, so a binding for a code an earlier version
|
||||
// granted and the current one dropped is left in place, stale. Reconciling
|
||||
// that is deleting something, which needs the same certainty about
|
||||
// ownership uninstall's design (see design doc §5) already requires -
|
||||
// this function has no way to tell "stale, from an older version of this
|
||||
// same app" apart from "hand-added, for a reason", and business rule 3
|
||||
// ("uninstall deletes only what it can attribute with certainty") applies
|
||||
// here just as much as it does there. Reconciling stale seed-driven
|
||||
// bindings, if it is ever wanted, belongs in the upgrade path with that
|
||||
// same ownership check - not silently inside every retry of every install.
|
||||
func repairExistingMenu(tx *gorm.DB, existing models.SysMenu, appCode string, s seed.MenuSpec, parentRow models.SysMenu, apiRows map[string]models.SysApi) (models.SysMenu, error) {
|
||||
// Every column the spec decides, not just the two this used to touch. A
|
||||
// menu whose parent was removed and reseeded kept parent_id pointing at
|
||||
// the dead row while its paths named the new one, and the tree is built
|
||||
// from parent_id - so the menu vanished from the sidebar with the
|
||||
// migration reporting success. An application that renamed a menu or
|
||||
// moved its component between versions had its change silently ignored
|
||||
// for the same reason: nothing here wrote those columns.
|
||||
want := menuRowFor(appCode, s, parentRow)
|
||||
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", existing.MenuId).
|
||||
Select(specMenuFields).Updates(want).Error; err != nil {
|
||||
return models.SysMenu{}, fmt.Errorf("bringing the row up to the spec: %w", err)
|
||||
}
|
||||
want.MenuId = existing.MenuId
|
||||
want.Paths = existing.Paths
|
||||
want.Visible, want.IsFrame = existing.Visible, existing.IsFrame
|
||||
|
||||
if err := repairPaths(tx, &want, s, parentRow); err != nil {
|
||||
return models.SysMenu{}, err
|
||||
}
|
||||
existing = want
|
||||
|
||||
for _, code := range s.ApiCodes {
|
||||
api, ok := apiRows[code]
|
||||
if !ok {
|
||||
return models.SysMenu{}, fmt.Errorf("ApiCodes references %q, which is not an ApiSpec.Code in this call", code)
|
||||
}
|
||||
if err := tx.Exec(
|
||||
"INSERT INTO sys_menu_api_rule (sys_menu_menu_id, sys_api_id) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM sys_menu_api_rule WHERE sys_menu_menu_id = ? AND sys_api_id = ?)",
|
||||
existing.MenuId, api.Id, existing.MenuId, api.Id,
|
||||
).Error; err != nil {
|
||||
return models.SysMenu{}, fmt.Errorf("binding %q: %w", code, err)
|
||||
}
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// validateMenuSpec rejects the malformed input tools/checksilent's
|
||||
// menu-sort-overflow and Kind-adjacent checks would catch for an in-tree
|
||||
// seed but cannot for a third-party application's - see menuSortRange's doc
|
||||
@@ -278,7 +426,7 @@ func pascalCase(s string) string {
|
||||
// framework migration sorts before every app-prefixed one - means that
|
||||
// should not happen in practice, but failing this call over it would be
|
||||
// worse than a menu with no grant yet.
|
||||
func grantToAdminRole(tx *gorm.DB, menuIDs []int, apiRows map[string]models.SysApi) error {
|
||||
func grantToAdminRole(tx *gorm.DB, appCode string, menuIDs []int, apiRows map[string]models.SysApi) error {
|
||||
var role models.SysRole
|
||||
if err := tx.Where("role_key = ?", adminRoleKey).First(&role).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -297,12 +445,177 @@ func grantToAdminRole(tx *gorm.DB, menuIDs []int, apiRows map[string]models.SysA
|
||||
}
|
||||
|
||||
for _, a := range apiRows {
|
||||
if err := tx.Exec(
|
||||
res := tx.Exec(
|
||||
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) SELECT 'p', ?, ?, ?, '', '', '' WHERE NOT EXISTS (SELECT 1 FROM casbin_rule WHERE ptype='p' AND v0=? AND v1=? AND v2=?)",
|
||||
role.RoleKey, a.Path, a.Action, role.RoleKey, a.Path, a.Action,
|
||||
).Error; err != nil {
|
||||
)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// The policy was already there, so this install did not create
|
||||
// it and it is not this app's to take away. Leaving it out of
|
||||
// the ledger is what makes an uninstall report it instead of
|
||||
// deleting it.
|
||||
//
|
||||
// The two ways this can be wrong are not equally bad, which is
|
||||
// what settles it. Under-recording leaves a policy behind and
|
||||
// the uninstall says so, because a policy naming an app's own
|
||||
// path with no ledger entry is exactly what it lists as an
|
||||
// orphan. Over-recording deletes a grant somebody else made,
|
||||
// silently. Between a visible leftover and an invisible
|
||||
// deletion of somebody's authorization, take the leftover.
|
||||
continue
|
||||
}
|
||||
if err := recordGrant(tx, appCode, role.RoleKey, a.Path, a.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordGrant writes down that this application's install created one casbin
|
||||
// policy, keyed by the same tuple casbin_rule is unique on.
|
||||
//
|
||||
// A ledger rather than a column on casbin_rule, because casbin_rule is not
|
||||
// this project's table: the gorm adapter's SavePolicy truncates it and writes
|
||||
// it back from memory, which would drop any column added here, and
|
||||
// SysRole.Update replaces a role's policy rows wholesale. The tuple survives
|
||||
// both, because both rebuild it from the same sys_menu/sys_api data.
|
||||
//
|
||||
// Written with the same INSERT ... WHERE NOT EXISTS shape as the policy above
|
||||
// rather than a plain insert: the ledger's unique index covers the tuple
|
||||
// alone, so a duplicate would abort the whole seed instead of being the
|
||||
// no-op it should be.
|
||||
func recordGrant(tx *gorm.DB, appCode, roleKey, path, action string) error {
|
||||
return tx.Exec(
|
||||
"INSERT INTO sys_app_casbin_grant (app_code, ptype, v0, v1, v2, v3, v4, v5, created_at) "+
|
||||
"SELECT ?, 'p', ?, ?, ?, '', '', '', ? WHERE NOT EXISTS "+
|
||||
"(SELECT 1 FROM sys_app_casbin_grant WHERE ptype='p' AND v0=? AND v1=? AND v2=? AND v3='' AND v4='' AND v5='')",
|
||||
appCode, roleKey, path, action, time.Now(), roleKey, path, action,
|
||||
).Error
|
||||
}
|
||||
|
||||
// specMenuFields are the sys_menu columns a MenuSpec decides, and the only
|
||||
// ones a reseed rewrites on a row that is already there.
|
||||
//
|
||||
// Visible and IsFrame are not in the list. They are seeding defaults the
|
||||
// application never expressed, so an administrator who hid a seeded menu
|
||||
// keeps it hidden. app_code and seed_code are not either: they are the
|
||||
// natural key the row was found by, and writing them back would be writing
|
||||
// what was just matched.
|
||||
var specMenuFields = []string{
|
||||
"MenuName", "Title", "Icon", "Path", "MenuType",
|
||||
"Permission", "ParentId", "Component", "Sort",
|
||||
}
|
||||
|
||||
// menuRowFor is the row a MenuSpec describes. One definition, so the insert
|
||||
// path and the repair path cannot drift into disagreeing about what a spec
|
||||
// decides.
|
||||
func menuRowFor(appCode string, s seed.MenuSpec, parentRow models.SysMenu) models.SysMenu {
|
||||
seedCode := s.Code
|
||||
return models.SysMenu{
|
||||
MenuName: menuName(appCode, s.Code),
|
||||
Title: s.Title,
|
||||
Icon: s.Icon,
|
||||
Path: s.Path,
|
||||
MenuType: s.Kind,
|
||||
Permission: s.Permission,
|
||||
ParentId: parentRow.MenuId,
|
||||
Component: s.Component,
|
||||
Sort: s.Sort,
|
||||
// Visible "0" is shown, not hidden - the same defaults
|
||||
// 1786700001000_demo_menu.go seeds its own menu with. A freshly
|
||||
// installed application's menu should not need an administrator to
|
||||
// first find and unhide it. Only written when the row is created;
|
||||
// see specMenuFields.
|
||||
Visible: "0",
|
||||
IsFrame: "1",
|
||||
AppCode: appCode,
|
||||
SeedCode: &seedCode,
|
||||
}
|
||||
}
|
||||
|
||||
// repairPaths writes row.Paths, and moves whatever is underneath it.
|
||||
//
|
||||
// The subtree matters because it is not all in this call's specs: a menu an
|
||||
// administrator added under a seeded one keeps the old prefix, and nothing
|
||||
// else in the codebase would ever rewrite it. SysMenu.Update does the same
|
||||
// cascade for the same column when somebody moves a menu by hand.
|
||||
//
|
||||
// The predicate is the row itself or a row strictly under it, rather than
|
||||
// `paths LIKE old || '%'`, which also matches /0/10 when old is /0/1.
|
||||
func repairPaths(tx *gorm.DB, row *models.SysMenu, s seed.MenuSpec, parentRow models.SysMenu) error {
|
||||
want := expectedPaths(row.MenuId, s.Parent, parentRow)
|
||||
old := row.Paths
|
||||
if old == want {
|
||||
return nil
|
||||
}
|
||||
if old == "" {
|
||||
// A row whose paths was never written - an interrupted create. It
|
||||
// has no subtree to speak of, and `LIKE '/%'` would match the whole
|
||||
// table.
|
||||
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", row.MenuId).
|
||||
Update("paths", want).Error; err != nil {
|
||||
return fmt.Errorf("writing paths: %w", err)
|
||||
}
|
||||
row.Paths = want
|
||||
return nil
|
||||
}
|
||||
|
||||
var subtree []models.SysMenu
|
||||
if err := tx.Where("paths = ? OR paths LIKE ?", old, old+"/%").Find(&subtree).Error; err != nil {
|
||||
return fmt.Errorf("reading the subtree under %s: %w", old, err)
|
||||
}
|
||||
for _, d := range subtree {
|
||||
moved := want + strings.TrimPrefix(d.Paths, old)
|
||||
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", d.MenuId).
|
||||
Update("paths", moved).Error; err != nil {
|
||||
return fmt.Errorf("moving %d from %s to %s: %w", d.MenuId, d.Paths, moved, err)
|
||||
}
|
||||
}
|
||||
row.Paths = want
|
||||
return nil
|
||||
}
|
||||
|
||||
// adoptLegacyMenu claims a row this application wrote before sys_menu had a
|
||||
// seed_code column, so a reseed repairs it instead of inserting a second copy
|
||||
// beside it.
|
||||
//
|
||||
// 1786700008000 added the column and left it NULL on every row already there,
|
||||
// which is right for the host's own hand-placed menus - there is nothing to
|
||||
// derive one from. An application's rows are in that population too, and for
|
||||
// those the value is derivable, because menu_name is what identified them
|
||||
// before the column existed. Without this the natural-key lookup misses them,
|
||||
// the seed inserts a duplicate, and the unique index cannot object: NULL
|
||||
// never collides.
|
||||
//
|
||||
// Ambiguity is refused rather than guessed. menuName concatenates two
|
||||
// pascalCase strings and pascalCase is not injective, so two specs can land
|
||||
// on one name; picking one of several rows would attach an application's
|
||||
// menu to whichever the database returned first.
|
||||
func adoptLegacyMenu(tx *gorm.DB, appCode string, s seed.MenuSpec) (models.SysMenu, bool, error) {
|
||||
name := menuName(appCode, s.Code)
|
||||
var rows []models.SysMenu
|
||||
if err := tx.Where("app_code = ? AND menu_name = ? AND seed_code IS NULL", appCode, name).
|
||||
Find(&rows).Error; err != nil {
|
||||
return models.SysMenu{}, false, fmt.Errorf("looking for a row written before seed_code existed: %w", err)
|
||||
}
|
||||
switch len(rows) {
|
||||
case 0:
|
||||
return models.SysMenu{}, false, nil
|
||||
case 1:
|
||||
default:
|
||||
return models.SysMenu{}, false, fmt.Errorf(
|
||||
"%d rows carry menu_name %q with no seed_code; which of them belongs to %q cannot be decided here, because menuName is not reversible - reconcile them by hand",
|
||||
len(rows), name, s.Code)
|
||||
}
|
||||
|
||||
seedCode := s.Code
|
||||
if err := tx.Model(&models.SysMenu{}).Where("menu_id = ?", rows[0].MenuId).
|
||||
Update("seed_code", seedCode).Error; err != nil {
|
||||
return models.SysMenu{}, false, fmt.Errorf("claiming the row written before seed_code existed: %w", err)
|
||||
}
|
||||
rows[0].SeedCode = &seedCode
|
||||
return rows[0], true, nil
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
|
||||
contractmodels "github.com/go-admin-team/go-admin-core/v2/sdk/contract/models"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
|
||||
@@ -28,7 +33,14 @@ func newSeedTestDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SysMenu{}, &models.SysApi{}, &models.SysRole{}); err != nil {
|
||||
// sys_app_casbin_grant is where grantToAdminRole records which policies
|
||||
// this install created, so an uninstall can tell them from the ones
|
||||
// somebody granted by hand. In a real database it is created by
|
||||
// 1786700007000, which is a framework migration and therefore runs ahead
|
||||
// of every application's - version strings sort bare digits before any
|
||||
// app-prefixed one.
|
||||
if err := db.AutoMigrate(&models.SysMenu{}, &models.SysApi{}, &models.SysRole{},
|
||||
&models.SysAppCasbinGrant{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE casbin_rule (
|
||||
@@ -289,3 +301,953 @@ func TestSeedMenusWithNothingRegisteredWritesNothing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newSeedTestDB's AutoMigrate builds a unique index on seed_code alone,
|
||||
// because SysMenu.SeedCode is the only field in the struct carrying the
|
||||
// uk_sys_menu_app_seed_code_del tag - app_code already carries a different,
|
||||
// non-unique index name of its own, and the embedded ModelTime's
|
||||
// DeletedAt (aliased from go-admin-core) cannot be given a third one. The
|
||||
// real migration (cmd/migrate/migration/version/1786700008000_seed_natural_keys.go)
|
||||
// never lets AutoMigrate touch this table for exactly that reason: it
|
||||
// builds the composite (app_code, seed_code, deleted_at) index by hand
|
||||
// instead. Reproduce that by hand here too, so a test that seeds two rows
|
||||
// sharing a seed_code under different deleted_at values sees what a real
|
||||
// install would, not gorm's narrower default.
|
||||
func useCompositeSeedCodeIndex(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if db.Migrator().HasIndex(&models.SysMenu{}, "uk_sys_menu_app_seed_code_del") {
|
||||
if err := db.Migrator().DropIndex(&models.SysMenu{}, "uk_sys_menu_app_seed_code_del"); err != nil {
|
||||
t.Fatalf("drop the single-column seed_code index: %v", err)
|
||||
}
|
||||
}
|
||||
if err := db.Exec(
|
||||
"CREATE UNIQUE INDEX uk_sys_menu_app_seed_code_del ON sys_menu (app_code, seed_code, deleted_at)",
|
||||
).Error; err != nil {
|
||||
t.Fatalf("create the composite seed_code index: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A retried migration - one that failed partway through and is run again,
|
||||
// or simply run twice by mistake - must not create a second sys_api or
|
||||
// sys_menu row for the same (appCode, natural key). This is the defect the
|
||||
// demo site hit in production: duplicate sys_menu/casbin_rule rows from a
|
||||
// bare tx.Create on a natural key nothing was checking.
|
||||
func TestSeedMenusIsIdempotentAcrossARetry(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
useCompositeSeedCodeIndex(t, db)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
menus := []seed.MenuSpec{
|
||||
{Code: "dir", Kind: contractmodels.Directory, Title: "Order", Sort: 10},
|
||||
{Code: "list", Parent: "dir", Kind: contractmodels.Menu, Title: "Orders", Sort: 1, ApiCodes: []string{"list"}},
|
||||
}
|
||||
apis := []seed.ApiSpec{
|
||||
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"},
|
||||
}
|
||||
|
||||
run := func() {
|
||||
t.Helper()
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
||||
}); err != nil {
|
||||
t.Fatalf("SeedMenus: %v", err)
|
||||
}
|
||||
}
|
||||
run()
|
||||
firstMenuIDs := allMenuIDs(t, db, "order")
|
||||
firstApiIDs := allApiIDs(t, db, "order")
|
||||
|
||||
run() // the retry
|
||||
|
||||
if got := allMenuIDs(t, db, "order"); !sameIDs(got, firstMenuIDs) {
|
||||
t.Errorf("sys_menu ids after retry = %v, want unchanged %v (a second call inserted new rows)", got, firstMenuIDs)
|
||||
}
|
||||
if got := allApiIDs(t, db, "order"); !sameIDs(got, firstApiIDs) {
|
||||
t.Errorf("sys_api ids after retry = %v, want unchanged %v (a second call inserted new rows)", got, firstApiIDs)
|
||||
}
|
||||
|
||||
assertRowCount(t, db, "sys_api", 1)
|
||||
assertRowCount(t, db, "sys_menu", 2)
|
||||
assertRowCount(t, db, "sys_menu_api_rule", 1)
|
||||
assertRowCount(t, db, "sys_role_menu", 2)
|
||||
assertRowCount(t, db, "casbin_rule", 1)
|
||||
}
|
||||
|
||||
// Only a live row counts as "already written". A row a prior, unrelated
|
||||
// soft-delete already retired must not be reused - seedApis/seedMenuTree
|
||||
// have to insert a fresh one under the same natural key, the same way the
|
||||
// unique indexes 1786700008000_seed_natural_keys.go builds only bind live
|
||||
// rows.
|
||||
func TestSeedMenusOnlyReusesLiveRows(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
useCompositeSeedCodeIndex(t, db)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
menus := []seed.MenuSpec{{Code: "dir", Kind: contractmodels.Directory, Title: "Order", Sort: 10}}
|
||||
apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}}
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
||||
}); err != nil {
|
||||
t.Fatalf("SeedMenus: %v", err)
|
||||
}
|
||||
|
||||
// Soft-delete both rows this first call wrote, as if an operator (or an
|
||||
// earlier uninstall) had retired them, independently of this migration
|
||||
// ever running again.
|
||||
if err := db.Exec("UPDATE sys_menu SET deleted_at = 1").Error; err != nil {
|
||||
t.Fatalf("soft-delete sys_menu: %v", err)
|
||||
}
|
||||
if err := db.Exec("UPDATE sys_api SET deleted_at = 1").Error; err != nil {
|
||||
t.Fatalf("soft-delete sys_api: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
||||
}); err != nil {
|
||||
t.Fatalf("SeedMenus after soft-delete: %v", err)
|
||||
}
|
||||
|
||||
// Two rows total: the soft-deleted original, plus a fresh one - not the
|
||||
// dead row resurrected in place, and not left with zero live rows.
|
||||
assertRowCount(t, db, "sys_menu", 2)
|
||||
assertRowCount(t, db, "sys_api", 2)
|
||||
|
||||
var liveMenus, liveApis int64
|
||||
db.Model(&models.SysMenu{}).Where("app_code = ?", "order").Count(&liveMenus)
|
||||
db.Model(&models.SysApi{}).Where("app_code = ?", "order").Count(&liveApis)
|
||||
if liveMenus != 1 {
|
||||
t.Errorf("live sys_menu rows = %d, want 1", liveMenus)
|
||||
}
|
||||
if liveApis != 1 {
|
||||
t.Errorf("live sys_api rows = %d, want 1", liveApis)
|
||||
}
|
||||
}
|
||||
|
||||
// app_code is part of the natural key, not a descriptive column alongside
|
||||
// it. Two applications that happen to register an identical (path, action)
|
||||
// or seed_code must each get their own row - reusing one app's row for
|
||||
// another's install would make an uninstall of the first delete a row the
|
||||
// second considers its own.
|
||||
func TestSeedMenusScopesTheNaturalKeyByAppCode(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
useCompositeSeedCodeIndex(t, db)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
menus := []seed.MenuSpec{{Code: "dir", Kind: contractmodels.Directory, Title: "Dir", Sort: 10}}
|
||||
apis := []seed.ApiSpec{{Code: "list", Title: "Shared endpoint", Path: "/api/v1/shared", Method: "GET"}}
|
||||
|
||||
for _, appCode := range []string{"order", "billing"} {
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return adminSeeder{}.SeedMenus(tx, appCode, menus, apis)
|
||||
}); err != nil {
|
||||
t.Fatalf("SeedMenus(%q): %v", appCode, err)
|
||||
}
|
||||
}
|
||||
|
||||
var apiRows []models.SysApi
|
||||
if err := db.Where("path = ? AND action = ?", "/api/v1/shared", "GET").
|
||||
Order("app_code").Find(&apiRows).Error; err != nil {
|
||||
t.Fatalf("read sys_api: %v", err)
|
||||
}
|
||||
if len(apiRows) != 2 {
|
||||
t.Fatalf("sys_api has %d row(s) for the shared (path, action), want 2 - one per app", len(apiRows))
|
||||
}
|
||||
if apiRows[0].AppCode != "billing" || apiRows[1].AppCode != "order" {
|
||||
t.Errorf("sys_api app_codes = [%s %s], want [billing order]", apiRows[0].AppCode, apiRows[1].AppCode)
|
||||
}
|
||||
|
||||
var menuRows []models.SysMenu
|
||||
if err := db.Where("seed_code = ?", "dir").Order("app_code").Find(&menuRows).Error; err != nil {
|
||||
t.Fatalf("read sys_menu: %v", err)
|
||||
}
|
||||
if len(menuRows) != 2 {
|
||||
t.Fatalf("sys_menu has %d row(s) for the shared seed_code, want 2 - one per app", len(menuRows))
|
||||
}
|
||||
if menuRows[0].AppCode != "billing" || menuRows[1].AppCode != "order" {
|
||||
t.Errorf("sys_menu app_codes = [%s %s], want [billing order]", menuRows[0].AppCode, menuRows[1].AppCode)
|
||||
}
|
||||
}
|
||||
|
||||
func allMenuIDs(t *testing.T, db *gorm.DB, appCode string) []int {
|
||||
t.Helper()
|
||||
var rows []models.SysMenu
|
||||
if err := db.Where("app_code = ?", appCode).Order("menu_id").Find(&rows).Error; err != nil {
|
||||
t.Fatalf("read sys_menu: %v", err)
|
||||
}
|
||||
ids := make([]int, len(rows))
|
||||
for i, r := range rows {
|
||||
ids[i] = r.MenuId
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func allApiIDs(t *testing.T, db *gorm.DB, appCode string) []int {
|
||||
t.Helper()
|
||||
var rows []models.SysApi
|
||||
if err := db.Where("app_code = ?", appCode).Order("id").Find(&rows).Error; err != nil {
|
||||
t.Fatalf("read sys_api: %v", err)
|
||||
}
|
||||
ids := make([]int, len(rows))
|
||||
for i, r := range rows {
|
||||
ids[i] = r.Id
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func sameIDs(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func assertRowCount(t *testing.T, db *gorm.DB, table string, want int64) {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := db.Table(table).Count(&n).Error; err != nil {
|
||||
t.Fatalf("count %s: %v", table, err)
|
||||
}
|
||||
if n != want {
|
||||
t.Errorf("%s has %d row(s), want %d", table, n, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A retried migration does not just risk inserting a second copy of a row
|
||||
// it already wrote (that gap is closed above) - the reuse path itself has
|
||||
// to leave the row in the same state a fresh insert would have. Before
|
||||
// this defect was fixed, the reuse branch (seed.go's "case err == nil")
|
||||
// stopped at reusing the row's id and skipped everything a fresh insert
|
||||
// does afterwards: the sys_menu_api_rule binding gorm's association save
|
||||
// writes as part of Create, and the paths UPDATE that follows Create as a
|
||||
// separate statement. A row a prior attempt inserted but did not finish -
|
||||
// exactly the shape design doc §1.5 says a non-transactional retry can
|
||||
// leave behind - would be "found" and then left broken forever, with the
|
||||
// migration reporting success.
|
||||
//
|
||||
// existingHalfWrittenMenu inserts a sys_menu row the way seedMenuTree's own
|
||||
// tx.Create leaves one when interrupted immediately afterwards: the row
|
||||
// exists with its natural key, but paths was never computed and no
|
||||
// sys_menu_api_rule binding was ever written for it - Create's association
|
||||
// save and the paths UPDATE are each a separate statement from the row
|
||||
// insert itself.
|
||||
func existingHalfWrittenMenu(t *testing.T, db *gorm.DB, appCode, seedCode string, parentID int) models.SysMenu {
|
||||
t.Helper()
|
||||
code := seedCode
|
||||
row := models.SysMenu{
|
||||
MenuName: menuName(appCode, seedCode),
|
||||
AppCode: appCode,
|
||||
SeedCode: &code,
|
||||
ParentId: parentID,
|
||||
Visible: "0",
|
||||
IsFrame: "1",
|
||||
// Paths deliberately left "" - never computed, the same as a row
|
||||
// whose Create succeeded but whose follow-up paths UPDATE never ran.
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seed half-written menu %q: %v", seedCode, err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func bindingCount(t *testing.T, db *gorm.DB, menuID, apiID int) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := db.Table("sys_menu_api_rule").
|
||||
Where("sys_menu_menu_id = ? AND sys_api_id = ?", menuID, apiID).Count(&n).Error; err != nil {
|
||||
t.Fatalf("count sys_menu_api_rule: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestSeedMenusRepairsAnIncompleteExistingRow is the reproduction case:
|
||||
// both paths and the api binding are missing on the row seedMenuTree finds
|
||||
// through its idempotency check, the shape a real interrupted retry leaves
|
||||
// behind. Run against the unfixed reuse branch, this must fail - that is
|
||||
// what proves the defect is real rather than a three-way guess.
|
||||
func TestSeedMenusRepairsAnIncompleteExistingRow(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
useCompositeSeedCodeIndex(t, db)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}}
|
||||
apiRows, err := seedApis(db, "order", apis)
|
||||
if err != nil {
|
||||
t.Fatalf("seedApis: %v", err)
|
||||
}
|
||||
|
||||
dir := existingHalfWrittenMenu(t, db, "order", "dir", 0)
|
||||
list := existingHalfWrittenMenu(t, db, "order", "list", dir.MenuId)
|
||||
|
||||
menus := []seed.MenuSpec{
|
||||
{Code: "dir", Kind: contractmodels.Directory, Title: "Order", Sort: 10},
|
||||
{Code: "list", Parent: "dir", Kind: contractmodels.Menu, Title: "Orders", Sort: 1, ApiCodes: []string{"list"}},
|
||||
}
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
||||
}); err != nil {
|
||||
t.Fatalf("SeedMenus: %v", err)
|
||||
}
|
||||
|
||||
wantDirPaths := "/0/" + strconv.Itoa(dir.MenuId)
|
||||
wantListPaths := wantDirPaths + "/" + strconv.Itoa(list.MenuId)
|
||||
|
||||
var gotDir, gotList models.SysMenu
|
||||
if err := db.First(&gotDir, dir.MenuId).Error; err != nil {
|
||||
t.Fatalf("read dir: %v", err)
|
||||
}
|
||||
if err := db.First(&gotList, list.MenuId).Error; err != nil {
|
||||
t.Fatalf("read list: %v", err)
|
||||
}
|
||||
if gotDir.Paths != wantDirPaths {
|
||||
t.Errorf("dir.Paths = %q, want %q - a retried install left a root menu with no materialized path", gotDir.Paths, wantDirPaths)
|
||||
}
|
||||
if gotList.Paths != wantListPaths {
|
||||
t.Errorf("list.Paths = %q, want %q - a retried install left the seeded subtree with a broken materialized path", gotList.Paths, wantListPaths)
|
||||
}
|
||||
if n := bindingCount(t, db, list.MenuId, apiRows["list"].Id); n != 1 {
|
||||
t.Errorf("sys_menu_api_rule binding count for list = %d, want 1 - a retried install left the menu with its api granted to no one", n)
|
||||
}
|
||||
}
|
||||
|
||||
// soloMenuSpec is a single, parent-less menu with one api binding - the
|
||||
// smallest shape that can exhibit "paths wrong" and "binding missing"
|
||||
// independently of each other, used by the three tests below to isolate
|
||||
// one repair path at a time from TestSeedMenusRepairsAnIncompleteExistingRow's
|
||||
// combined (both broken) case.
|
||||
func soloMenuSpec() []seed.MenuSpec {
|
||||
return []seed.MenuSpec{{Code: "solo", Kind: contractmodels.Menu, Title: "Solo", Sort: 1, ApiCodes: []string{"list"}}}
|
||||
}
|
||||
|
||||
// Only the binding is missing; paths is already correct. The repair must
|
||||
// add the binding and must not touch the already-correct paths value.
|
||||
func TestSeedMenusRepairsOnlyAMissingBinding(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
useCompositeSeedCodeIndex(t, db)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}}
|
||||
apiRows, err := seedApis(db, "order", apis)
|
||||
if err != nil {
|
||||
t.Fatalf("seedApis: %v", err)
|
||||
}
|
||||
|
||||
solo := existingHalfWrittenMenu(t, db, "order", "solo", 0)
|
||||
wantPaths := "/0/" + strconv.Itoa(solo.MenuId)
|
||||
if err := db.Model(&models.SysMenu{}).Where("menu_id = ?", solo.MenuId).
|
||||
Update("paths", wantPaths).Error; err != nil {
|
||||
t.Fatalf("set paths: %v", err)
|
||||
}
|
||||
// The binding is deliberately left unwritten.
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return adminSeeder{}.SeedMenus(tx, "order", soloMenuSpec(), apis)
|
||||
}); err != nil {
|
||||
t.Fatalf("SeedMenus: %v", err)
|
||||
}
|
||||
|
||||
var got models.SysMenu
|
||||
if err := db.First(&got, solo.MenuId).Error; err != nil {
|
||||
t.Fatalf("read solo: %v", err)
|
||||
}
|
||||
if got.Paths != wantPaths {
|
||||
t.Errorf("paths changed from %q to %q; repairing a missing binding must not touch an already-correct path", wantPaths, got.Paths)
|
||||
}
|
||||
if n := bindingCount(t, db, solo.MenuId, apiRows["list"].Id); n != 1 {
|
||||
t.Errorf("binding count = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Only paths is missing; the binding already exists (as if Create's own
|
||||
// association write had succeeded but the paths UPDATE that follows it
|
||||
// never ran). The repair must fix paths and must not duplicate the
|
||||
// already-correct binding.
|
||||
func TestSeedMenusRepairsOnlyMissingPaths(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
useCompositeSeedCodeIndex(t, db)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}}
|
||||
apiRows, err := seedApis(db, "order", apis)
|
||||
if err != nil {
|
||||
t.Fatalf("seedApis: %v", err)
|
||||
}
|
||||
|
||||
solo := existingHalfWrittenMenu(t, db, "order", "solo", 0)
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_menu_api_rule (sys_menu_menu_id, sys_api_id) VALUES (?, ?)",
|
||||
solo.MenuId, apiRows["list"].Id,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seed binding: %v", err)
|
||||
}
|
||||
// solo.Paths is deliberately left "" by existingHalfWrittenMenu.
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return adminSeeder{}.SeedMenus(tx, "order", soloMenuSpec(), apis)
|
||||
}); err != nil {
|
||||
t.Fatalf("SeedMenus: %v", err)
|
||||
}
|
||||
|
||||
wantPaths := "/0/" + strconv.Itoa(solo.MenuId)
|
||||
var got models.SysMenu
|
||||
if err := db.First(&got, solo.MenuId).Error; err != nil {
|
||||
t.Fatalf("read solo: %v", err)
|
||||
}
|
||||
if got.Paths != wantPaths {
|
||||
t.Errorf("paths = %q, want %q", got.Paths, wantPaths)
|
||||
}
|
||||
if n := bindingCount(t, db, solo.MenuId, apiRows["list"].Id); n != 1 {
|
||||
t.Errorf("binding count = %d, want 1 - repairing paths must not duplicate an already-correct binding", n)
|
||||
}
|
||||
}
|
||||
|
||||
// capturingLogger records every SQL statement gorm actually executes, so a
|
||||
// test can assert that a fully-consistent retry performs no write at all -
|
||||
// not just that its net effect happens to be zero rows changed. Mirrors
|
||||
// common/actions/crud_shim_test.go's logger of the same name and shape;
|
||||
// duplicated locally rather than exported and shared, matching how small
|
||||
// gorm-facing test doubles are kept next to the test that needs them
|
||||
// elsewhere in this repository.
|
||||
type capturingLogger struct {
|
||||
gormlogger.Interface
|
||||
mu sync.Mutex
|
||||
stmts []string
|
||||
}
|
||||
|
||||
func (l *capturingLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
|
||||
sql, _ := fc()
|
||||
l.mu.Lock()
|
||||
l.stmts = append(l.stmts, sql)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *capturingLogger) all() string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return strings.Join(l.stmts, "\n")
|
||||
}
|
||||
|
||||
// Both paths and the binding are already correct - the ordinary shape of
|
||||
// every retry after the first one succeeds in full. Repairing an
|
||||
// already-consistent row must not touch it: paths is read-before-write and
|
||||
// so must not be UPDATEd at all (asserted directly, by statement, since the
|
||||
// code gates that call behind a value comparison); the binding's own
|
||||
// insert is guarded by WHERE NOT EXISTS the same way grantToAdminRole's
|
||||
// already are, so its row count staying put is the meaningful claim - the
|
||||
// guarded statement itself may still be sent, the same way it already is
|
||||
// for sys_role_menu/casbin_rule.
|
||||
func TestSeedMenusFullyConsistentRowCausesNoPathsUpdate(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
useCompositeSeedCodeIndex(t, db)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}}
|
||||
menus := soloMenuSpec()
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
||||
}); err != nil {
|
||||
t.Fatalf("SeedMenus (first): %v", err)
|
||||
}
|
||||
|
||||
var apiRows []models.SysApi
|
||||
db.Where("app_code = ?", "order").Find(&apiRows)
|
||||
var soloRow models.SysMenu
|
||||
if err := db.Where("app_code = ? AND seed_code = ?", "order", "solo").First(&soloRow).Error; err != nil {
|
||||
t.Fatalf("read solo after first call: %v", err)
|
||||
}
|
||||
if soloRow.Paths == "" {
|
||||
t.Fatalf("solo.Paths is empty after the first call; the fixture itself is broken, not what this test means to check")
|
||||
}
|
||||
wantBindings := bindingCount(t, db, soloRow.MenuId, apiRows[0].Id)
|
||||
if wantBindings != 1 {
|
||||
t.Fatalf("binding count after the first call = %d, want 1; the fixture itself is broken", wantBindings)
|
||||
}
|
||||
|
||||
capturing := &capturingLogger{Interface: gormlogger.Default.LogMode(gormlogger.Info)}
|
||||
captured := db.Session(&gorm.Session{Logger: capturing})
|
||||
|
||||
if err := captured.Transaction(func(tx *gorm.DB) error {
|
||||
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
||||
}); err != nil {
|
||||
t.Fatalf("SeedMenus (retry): %v", err)
|
||||
}
|
||||
|
||||
all := strings.ToUpper(capturing.all())
|
||||
if strings.Contains(all, "UPDATE") && strings.Contains(all, "SYS_MENU") && strings.Contains(all, "PATHS") {
|
||||
t.Errorf("a fully consistent retry executed a paths UPDATE against sys_menu:\n%s", capturing.all())
|
||||
}
|
||||
if got := bindingCount(t, db, soloRow.MenuId, apiRows[0].Id); got != 1 {
|
||||
t.Errorf("binding count after the retry = %d, want 1 (unchanged)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An administrator can bind a menu to an additional api by hand through
|
||||
// the menu management UI - a sys_menu_api_rule row for an api never in
|
||||
// s.ApiCodes at all. A retried SeedMenus call must not touch it: deleting
|
||||
// every binding for the menu and reinserting only what s.ApiCodes lists
|
||||
// would wipe it out silently, the same shape as sys_role_menu/casbin_rule
|
||||
// getting zeroed by SysRole.Update's FullSaveAssociations save - just with
|
||||
// this code as the actor instead of the victim this time.
|
||||
func TestSeedMenusPreservesAHandAddedBinding(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
useCompositeSeedCodeIndex(t, db)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
apis := []seed.ApiSpec{{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET"}}
|
||||
menus := soloMenuSpec()
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
||||
}); err != nil {
|
||||
t.Fatalf("SeedMenus (first): %v", err)
|
||||
}
|
||||
|
||||
var soloRow models.SysMenu
|
||||
if err := db.Where("app_code = ? AND seed_code = ?", "order", "solo").First(&soloRow).Error; err != nil {
|
||||
t.Fatalf("read solo: %v", err)
|
||||
}
|
||||
|
||||
// An api this call's ApiSpec list never mentions - standing in for one
|
||||
// belonging to some other feature entirely, bound to this menu by an
|
||||
// administrator, not by any SeedMenus call.
|
||||
handAdded := models.SysApi{Path: "/api/v1/order/export", Action: "GET", Type: "SYS", AppCode: "order"}
|
||||
if err := db.Create(&handAdded).Error; err != nil {
|
||||
t.Fatalf("seed the hand-added api: %v", err)
|
||||
}
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_menu_api_rule (sys_menu_menu_id, sys_api_id) VALUES (?, ?)",
|
||||
soloRow.MenuId, handAdded.Id,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seed the hand-added binding: %v", err)
|
||||
}
|
||||
|
||||
// A retry with the exact same specs - solo's ApiCodes still names only
|
||||
// "list".
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return adminSeeder{}.SeedMenus(tx, "order", menus, apis)
|
||||
}); err != nil {
|
||||
t.Fatalf("SeedMenus (retry): %v", err)
|
||||
}
|
||||
|
||||
if n := bindingCount(t, db, soloRow.MenuId, handAdded.Id); n != 1 {
|
||||
t.Errorf("hand-added binding count = %d, want 1 - a retry silently deleted a binding it does not own", n)
|
||||
}
|
||||
|
||||
var apiRows []models.SysApi
|
||||
db.Where("app_code = ? AND path = ?", "order", "/api/v1/order").Find(&apiRows)
|
||||
if len(apiRows) != 1 {
|
||||
t.Fatalf("seeded api not found as expected: %+v", apiRows)
|
||||
}
|
||||
if n := bindingCount(t, db, soloRow.MenuId, apiRows[0].Id); n != 1 {
|
||||
t.Errorf("the seed's own binding count = %d, want 1 - it must survive the retry too", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Every policy grantToAdminRole creates has to be written down, or an
|
||||
// uninstall has no way to tell this app's grants from a hand-made one and
|
||||
// leaves all of them behind.
|
||||
func TestSeedMenusRecordsTheGrantsItCreated(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
role := seedAdminRole(t, db)
|
||||
|
||||
apis := []seed.ApiSpec{
|
||||
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
|
||||
{Code: "create", Title: "Create order", Path: "/api/v1/order", Method: "POST", Handle: "apis.Order.Insert-fm"},
|
||||
}
|
||||
if err := (adminSeeder{}).SeedMenus(db, "order", nil, apis); err != nil {
|
||||
t.Fatalf("SeedMenus: %v", err)
|
||||
}
|
||||
|
||||
for _, a := range apis {
|
||||
var n int64
|
||||
db.Model(&models.SysAppCasbinGrant{}).
|
||||
Where("app_code = ? AND ptype = 'p' AND v0 = ? AND v1 = ? AND v2 = ?",
|
||||
"order", role.RoleKey, a.Path, a.Method).
|
||||
Count(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("ledger rows for %s %s = %d, want 1", a.Method, a.Path, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A policy that was already there was not created by this install, so it is
|
||||
// not this app's to take away later. Recording it would mean an uninstall
|
||||
// deletes a grant somebody else made, and deletes it silently - the opposite
|
||||
// mistake leaves a policy behind, which the uninstall reports.
|
||||
func TestSeedMenusDoesNotClaimAPolicyItDidNotCreate(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
role := seedAdminRole(t, db)
|
||||
|
||||
if err := db.Exec(
|
||||
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', ?, '/api/v1/order', 'GET', '', '', '')",
|
||||
role.RoleKey,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("pre-existing policy: %v", err)
|
||||
}
|
||||
|
||||
apis := []seed.ApiSpec{
|
||||
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
|
||||
{Code: "create", Title: "Create order", Path: "/api/v1/order", Method: "POST", Handle: "apis.Order.Insert-fm"},
|
||||
}
|
||||
if err := (adminSeeder{}).SeedMenus(db, "order", nil, apis); err != nil {
|
||||
t.Fatalf("SeedMenus: %v", err)
|
||||
}
|
||||
|
||||
var claimed int64
|
||||
db.Model(&models.SysAppCasbinGrant{}).
|
||||
Where("v1 = ? AND v2 = ?", "/api/v1/order", "GET").Count(&claimed)
|
||||
if claimed != 0 {
|
||||
t.Errorf("the ledger claimed a policy that was already there (%d rows)", claimed)
|
||||
}
|
||||
// The one it did create is still recorded: the skip is per policy, not
|
||||
// for the whole call.
|
||||
var created int64
|
||||
db.Model(&models.SysAppCasbinGrant{}).
|
||||
Where("v1 = ? AND v2 = ?", "/api/v1/order", "POST").Count(&created)
|
||||
if created != 1 {
|
||||
t.Errorf("ledger rows for the policy it did create = %d, want 1", created)
|
||||
}
|
||||
// And the pre-existing policy itself is untouched.
|
||||
var policies int64
|
||||
db.Table("casbin_rule").Where("v1 = ? AND v2 = ?", "/api/v1/order", "GET").Count(&policies)
|
||||
if policies != 1 {
|
||||
t.Errorf("casbin_rule rows = %d, want the one that was already there", policies)
|
||||
}
|
||||
}
|
||||
|
||||
// A migration that failed partway is re-run whole. The ledger must come out
|
||||
// of a second run the same as the first, not with a duplicate or an error
|
||||
// from its own unique index.
|
||||
func TestSeedMenusLedgerSurvivesARetry(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
apis := []seed.ApiSpec{
|
||||
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := (adminSeeder{}).SeedMenus(db, "order", nil, apis); err != nil {
|
||||
t.Fatalf("SeedMenus run %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
var n int64
|
||||
db.Model(&models.SysAppCasbinGrant{}).Count(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("ledger has %d rows after two runs, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The ledger's own guard against a duplicate, which the plain retry above
|
||||
// never reaches: there the policy still exists, so the insert is skipped
|
||||
// before the ledger is touched at all. This is the case that does reach it -
|
||||
// the policy row was removed while its ledger entry stayed, so the seed
|
||||
// creates the policy again and writes a ledger entry that is already there.
|
||||
// A plain insert would abort the whole seed on the ledger's unique index.
|
||||
func TestSeedMenusLedgerToleratesAnEntryWhosePolicyWasRemoved(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
apis := []seed.ApiSpec{
|
||||
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
|
||||
}
|
||||
if err := (adminSeeder{}).SeedMenus(db, "order", nil, apis); err != nil {
|
||||
t.Fatalf("first run: %v", err)
|
||||
}
|
||||
if err := db.Exec("DELETE FROM casbin_rule WHERE v1 = ? AND v2 = ?", "/api/v1/order", "GET").Error; err != nil {
|
||||
t.Fatalf("removing the policy: %v", err)
|
||||
}
|
||||
var ledger int64
|
||||
db.Model(&models.SysAppCasbinGrant{}).Count(&ledger)
|
||||
if ledger != 1 {
|
||||
t.Fatalf("the ledger entry is gone, so this test is not set up: %d rows", ledger)
|
||||
}
|
||||
|
||||
if err := (adminSeeder{}).SeedMenus(db, "order", nil, apis); err != nil {
|
||||
t.Fatalf("second run: %v", err)
|
||||
}
|
||||
|
||||
db.Model(&models.SysAppCasbinGrant{}).Count(&ledger)
|
||||
if ledger != 1 {
|
||||
t.Errorf("ledger has %d rows, want 1", ledger)
|
||||
}
|
||||
var policies int64
|
||||
db.Table("casbin_rule").Where("v1 = ? AND v2 = ?", "/api/v1/order", "GET").Count(&policies)
|
||||
if policies != 1 {
|
||||
t.Errorf("the policy was not put back: %d rows", policies)
|
||||
}
|
||||
}
|
||||
|
||||
// The tree is built from parent_id, not from paths. A menu whose parent was
|
||||
// removed and written again kept parent_id on the dead row while its paths
|
||||
// named the new one, so the menu was gone from the sidebar and the migration
|
||||
// said it had succeeded.
|
||||
func TestSeedMenusRepairsParentIdAfterTheParentWasRemoved(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
useCompositeSeedCodeIndex(t, db)
|
||||
seedAdminRole(t, db)
|
||||
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
|
||||
|
||||
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
|
||||
t.Fatalf("first seed: %v", err)
|
||||
}
|
||||
var dir models.SysMenu
|
||||
if err := db.Where("app_code = ? AND seed_code = ?", "order", "dir").First(&dir).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Delete(&models.SysMenu{}, "menu_id = ?", dir.MenuId).Error; err != nil {
|
||||
t.Fatalf("removing the parent: %v", err)
|
||||
}
|
||||
|
||||
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
|
||||
t.Fatalf("second seed: %v", err)
|
||||
}
|
||||
|
||||
var newDir, list models.SysMenu
|
||||
if err := db.Where("app_code = ? AND seed_code = ?", "order", "dir").First(&newDir).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Where("app_code = ? AND seed_code = ?", "order", "list").First(&list).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if newDir.MenuId == dir.MenuId {
|
||||
t.Fatal("the removed parent was reused, so this test proves nothing")
|
||||
}
|
||||
if list.ParentId != newDir.MenuId {
|
||||
t.Errorf("parent_id = %d, want the new parent %d; the menu hangs off a row that is gone",
|
||||
list.ParentId, newDir.MenuId)
|
||||
}
|
||||
if want := newDir.Paths + "/" + strconv.Itoa(list.MenuId); list.Paths != want {
|
||||
t.Errorf("paths = %q, want %q", list.Paths, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A menu somebody added under a seeded one is not in any spec, so nothing but
|
||||
// this would ever rewrite its path when its ancestor moves.
|
||||
func TestSeedMenusMovesTheSubtreeUnderARepairedMenu(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
useCompositeSeedCodeIndex(t, db)
|
||||
seedAdminRole(t, db)
|
||||
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
|
||||
|
||||
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
|
||||
t.Fatalf("first seed: %v", err)
|
||||
}
|
||||
var dir, list models.SysMenu
|
||||
db.Where("app_code = ? AND seed_code = ?", "order", "dir").First(&dir)
|
||||
db.Where("app_code = ? AND seed_code = ?", "order", "list").First(&list)
|
||||
|
||||
// By hand, under the seeded menu, the way an administrator would.
|
||||
hand := models.SysMenu{MenuName: "HandMade", Title: "By hand", MenuType: contractmodels.Menu,
|
||||
ParentId: list.MenuId}
|
||||
if err := db.Create(&hand).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hand.Paths = list.Paths + "/" + strconv.Itoa(hand.MenuId)
|
||||
db.Model(&models.SysMenu{}).Where("menu_id = ?", hand.MenuId).Update("paths", hand.Paths)
|
||||
|
||||
// Rows whose paths start with the moving one's as a string and are not
|
||||
// underneath it as a path. /0/1/2 is a string prefix of /0/1/20, and a
|
||||
// LIKE on the bare prefix cannot tell the two apart - so these have to
|
||||
// be built against the path that actually moves, which is the one this
|
||||
// repair rewrites.
|
||||
var decoys []models.SysMenu
|
||||
for _, suffix := range []string{"0", "1", "9"} {
|
||||
d := models.SysMenu{MenuName: "Decoy" + suffix, Title: "decoy", MenuType: contractmodels.Menu}
|
||||
if err := db.Create(&d).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d.Paths = list.Paths + suffix
|
||||
db.Model(&models.SysMenu{}).Where("menu_id = ?", d.MenuId).Update("paths", d.Paths)
|
||||
decoys = append(decoys, d)
|
||||
}
|
||||
|
||||
if err := db.Delete(&models.SysMenu{}, "menu_id = ?", dir.MenuId).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
|
||||
t.Fatalf("second seed: %v", err)
|
||||
}
|
||||
|
||||
var newList, movedHand models.SysMenu
|
||||
db.Where("app_code = ? AND seed_code = ?", "order", "list").First(&newList)
|
||||
db.Where("menu_id = ?", hand.MenuId).First(&movedHand)
|
||||
if want := newList.Paths + "/" + strconv.Itoa(hand.MenuId); movedHand.Paths != want {
|
||||
t.Errorf("the hand-made menu's paths = %q, want %q; it no longer names its ancestors",
|
||||
movedHand.Paths, want)
|
||||
}
|
||||
for _, d := range decoys {
|
||||
var after models.SysMenu
|
||||
db.Where("menu_id = ?", d.MenuId).First(&after)
|
||||
if after.Paths != d.Paths {
|
||||
t.Errorf("decoy %d moved from %q to %q; a prefix match caught a row that is not underneath",
|
||||
d.MenuId, d.Paths, after.Paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An application that renames a menu or moves its component in a new version
|
||||
// had the change ignored: the row was found and returned untouched.
|
||||
func TestSeedMenusRefreshesWhatTheSpecDecides(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
useCompositeSeedCodeIndex(t, db)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
|
||||
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
|
||||
t.Fatalf("first seed: %v", err)
|
||||
}
|
||||
// An administrator hides it. That is not something the spec expresses,
|
||||
// so a reseed has no business turning it back on.
|
||||
if err := db.Model(&models.SysMenu{}).Where("app_code = ? AND seed_code = ?", "order", "list").
|
||||
Update("visible", "1").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
menus2, apis2 := orderMenuSpecs("Sales orders", "apps/order/list/index")
|
||||
if err := (adminSeeder{}).SeedMenus(db, "order", menus2, apis2); err != nil {
|
||||
t.Fatalf("second seed: %v", err)
|
||||
}
|
||||
|
||||
var list models.SysMenu
|
||||
db.Where("app_code = ? AND seed_code = ?", "order", "list").First(&list)
|
||||
if list.Title != "Sales orders" {
|
||||
t.Errorf("title = %q, want the new one", list.Title)
|
||||
}
|
||||
if list.Component != "apps/order/list/index" {
|
||||
t.Errorf("component = %q, want the new one", list.Component)
|
||||
}
|
||||
if list.Visible != "1" {
|
||||
t.Errorf("visible = %q; a reseed unhid a menu an administrator had hidden", list.Visible)
|
||||
}
|
||||
}
|
||||
|
||||
// orderMenuSpecs is a two-level tree plus one api, parameterised on the two
|
||||
// columns the upgrade test changes.
|
||||
func orderMenuSpecs(title, component string) ([]seed.MenuSpec, []seed.ApiSpec) {
|
||||
menus := []seed.MenuSpec{
|
||||
{Code: "dir", Kind: contractmodels.Directory, Title: "Order Example", Path: "/apps/order", Component: "Layout", Sort: 10},
|
||||
{Code: "list", Parent: "dir", Kind: contractmodels.Menu, Title: title, Path: "list", Component: component, Sort: 1, ApiCodes: []string{"list"}},
|
||||
}
|
||||
apis := []seed.ApiSpec{
|
||||
{Code: "list", Title: "Order list", Path: "/api/v1/order", Method: "GET", Handle: "apis.Order.GetPage-fm"},
|
||||
}
|
||||
return menus, apis
|
||||
}
|
||||
|
||||
// 1786700008000 added seed_code and left it NULL on every row already there.
|
||||
// An application's rows are in that population, and the natural-key lookup
|
||||
// misses them, so the seed used to insert a second copy beside each one -
|
||||
// which the unique index cannot object to, because NULL never collides.
|
||||
func TestSeedMenusAdoptsARowWrittenBeforeSeedCodeExisted(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
useCompositeSeedCodeIndex(t, db)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
// What an older SeedMenus left: app_code set, seed_code absent, and the
|
||||
// name that identified it then.
|
||||
legacy := models.SysMenu{
|
||||
MenuName: menuName("order", "dir"), AppCode: "order", Title: "the old title",
|
||||
MenuType: contractmodels.Directory, Path: "/apps/order", Component: "Layout", Sort: 10,
|
||||
}
|
||||
if err := db.Create(&legacy).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
|
||||
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
var rows []models.SysMenu
|
||||
if err := db.Where("app_code = ? AND menu_name = ?", "order", menuName("order", "dir")).
|
||||
Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("%d rows carry that name; the row from before the column existed was not found", len(rows))
|
||||
}
|
||||
if rows[0].MenuId != legacy.MenuId {
|
||||
t.Errorf("menu_id = %d, want the row that was already there (%d)", rows[0].MenuId, legacy.MenuId)
|
||||
}
|
||||
if rows[0].SeedCode == nil || *rows[0].SeedCode != "dir" {
|
||||
t.Errorf("seed_code = %v, want it claimed", rows[0].SeedCode)
|
||||
}
|
||||
// Adopted and then repaired, like any other existing row.
|
||||
if rows[0].Title != "Order Example" {
|
||||
t.Errorf("title = %q; the adopted row was not brought up to the spec", rows[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
// menuName concatenates two pascalCase strings and pascalCase is not
|
||||
// injective, so two specs can land on one name. Picking one of several rows
|
||||
// would attach an application's menu to whichever the database returned
|
||||
// first.
|
||||
func TestSeedMenusRefusesAnAmbiguousAdoption(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
useCompositeSeedCodeIndex(t, db)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
row := models.SysMenu{
|
||||
MenuName: menuName("order", "dir"), AppCode: "order", Title: fmt.Sprintf("copy %d", i),
|
||||
MenuType: contractmodels.Directory,
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
|
||||
err := (adminSeeder{}).SeedMenus(db, "order", menus, apis)
|
||||
if err == nil {
|
||||
t.Fatal("an ambiguous adoption was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "2 rows") || !strings.Contains(err.Error(), "by hand") {
|
||||
t.Errorf("error = %q, it has to say how many and that it is not deciding", err)
|
||||
}
|
||||
// And it did not write a third.
|
||||
var n int64
|
||||
db.Model(&models.SysMenu{}).Where("app_code = ? AND menu_name = ?", "order", menuName("order", "dir")).Count(&n)
|
||||
if n != 2 {
|
||||
t.Errorf("%d rows carry that name; the refusal still inserted", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A row belonging to another application, or to the host, carries a different
|
||||
// app_code and is not this application's to claim.
|
||||
func TestSeedMenusDoesNotAdoptAnotherApplicationsRow(t *testing.T) {
|
||||
db := newSeedTestDB(t)
|
||||
useCompositeSeedCodeIndex(t, db)
|
||||
seedAdminRole(t, db)
|
||||
|
||||
other := models.SysMenu{
|
||||
MenuName: menuName("order", "dir"), AppCode: "crm", Title: "crm's own",
|
||||
MenuType: contractmodels.Directory,
|
||||
}
|
||||
if err := db.Create(&other).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
menus, apis := orderMenuSpecs("Orders", "apps/order/index")
|
||||
if err := (adminSeeder{}).SeedMenus(db, "order", menus, apis); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
var after models.SysMenu
|
||||
db.Where("menu_id = ?", other.MenuId).First(&after)
|
||||
if after.SeedCode != nil || after.Title != "crm's own" {
|
||||
t.Errorf("another application's row was claimed: %+v", after)
|
||||
}
|
||||
var mine models.SysMenu
|
||||
if err := db.Where("app_code = ? AND seed_code = ?", "order", "dir").First(&mine).Error; err != nil {
|
||||
t.Fatalf("this application's own row was not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ func (e *SysDept) SetDeptLabel() (m []dto.DeptLabel, err error) {
|
||||
list := make([]models.SysDept, 0)
|
||||
err = e.Orm.Find(&list).Error
|
||||
if err != nil {
|
||||
log.Error("find dept list error, %s", err.Error())
|
||||
log.Errorf("find dept list error, %s", err.Error())
|
||||
return
|
||||
}
|
||||
m = make([]dto.DeptLabel, 0)
|
||||
|
||||
@@ -107,4 +107,4 @@ type SysRoleMenu struct {
|
||||
// return nil, err
|
||||
// }
|
||||
// return r, nil
|
||||
//}
|
||||
//}
|
||||
|
||||
+36
-17
@@ -97,13 +97,20 @@ LOOP:
|
||||
}
|
||||
|
||||
// Setup 初始化
|
||||
// Setup gives every tenant a scheduler and a supervisor to decide whether
|
||||
// this instance is the one that fills it.
|
||||
//
|
||||
// One owner id for the whole process, not one per tenant: the thing holding
|
||||
// the leases is this process, and a log line naming it should name the same
|
||||
// thing in every database it appears in.
|
||||
func Setup(dbs map[string]*gorm.DB) {
|
||||
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore Starting...")
|
||||
|
||||
owner := newOwnerID()
|
||||
for k, db := range dbs {
|
||||
sdk.Runtime.SetCrontabByTenant(k, cronjob.NewWithSeconds())
|
||||
setup(k, db)
|
||||
newSupervisor(k, db, owner).start()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +156,7 @@ func setup(key string, db *gorm.DB) {
|
||||
startCrontab(crontab)
|
||||
}
|
||||
|
||||
// startCrontab starts c and arranges for it to be stopped on the way out.
|
||||
// startCrontab starts c.
|
||||
//
|
||||
// The stop used to be `defer crontab.Stop()` followed by `select {}`. The
|
||||
// select never returned, so the defer never ran and the scheduler was never
|
||||
@@ -158,24 +165,36 @@ func setup(key string, db *gorm.DB) {
|
||||
// got a scheduler at all. cron.Start is itself `go c.run()`, so the select was
|
||||
// blocking for nothing.
|
||||
//
|
||||
// cron.Stop returns a context that closes once the jobs already running have
|
||||
// finished. That is the wait the shutdown budget exists to bound: giving up on
|
||||
// it leaves those jobs running until the process exits, which is better than
|
||||
// holding the whole shutdown open for one job that will not end.
|
||||
// Stopping is no longer arranged here. A scheduler now stops for two
|
||||
// different reasons - the process is going down, or this instance lost the
|
||||
// lease (#915) - and only the supervisor knows which. Registering a shutdown
|
||||
// callback per start, when a start happens every time the lease is taken,
|
||||
// would also add one callback per leadership change for the life of the
|
||||
// process: SetShutdown appends.
|
||||
func startCrontab(c *cron.Cron) {
|
||||
c.Start()
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore start success.")
|
||||
}
|
||||
|
||||
// 关闭任务
|
||||
sdk.Runtime.SetShutdown(func(ctx context.Context) {
|
||||
stopped := c.Stop()
|
||||
select {
|
||||
case <-stopped.Done():
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore stopped.")
|
||||
case <-ctx.Done():
|
||||
fmt.Println(time.Now().Format(timeFormat), " [WARN] JobCore stop gave up waiting for running jobs")
|
||||
}
|
||||
})
|
||||
// stopCrontab stops one tenant's scheduler and waits for the jobs already
|
||||
// running to finish, bounded by ctx.
|
||||
//
|
||||
// cron.Stop returns a context that closes once those jobs have finished.
|
||||
// That is the wait the shutdown budget exists to bound: giving up on it
|
||||
// leaves them running until the process exits, which is better than holding
|
||||
// the whole shutdown open for one job that will not end.
|
||||
func stopCrontab(ctx context.Context, key string) {
|
||||
c := sdk.Runtime.GetCrontabByTenant(key)
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
stopped := c.Stop()
|
||||
select {
|
||||
case <-stopped.Done():
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore stopped.")
|
||||
case <-ctx.Done():
|
||||
fmt.Println(time.Now().Format(timeFormat), " [WARN] JobCore stop gave up waiting for running jobs")
|
||||
}
|
||||
}
|
||||
|
||||
// AddJob 添加任务 AddJob(invokeTarget string, jobId int, jobName string, cronExpression string)
|
||||
@@ -209,7 +228,7 @@ func (e *ExecJob) addJob(c *cron.Cron) (int, error) {
|
||||
|
||||
// Remove 移除任务
|
||||
func Remove(c *cron.Cron, entryID int) chan bool {
|
||||
ch := make(chan bool)
|
||||
ch := make(chan bool, 1)
|
||||
go func() {
|
||||
c.Remove(cron.EntryID(entryID))
|
||||
fmt.Println(time.Now().Format(timeFormat), " [INFO] JobCore Remove success ,info entryID :", entryID)
|
||||
|
||||
@@ -6,26 +6,45 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
glebarez "github.com/glebarez/sqlite"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
|
||||
"gorm.io/gorm"
|
||||
|
||||
models2 "go-admin/app/jobs/models"
|
||||
)
|
||||
|
||||
// The scheduler had never been stopped. `defer crontab.Stop()` sat directly
|
||||
// above a `select {}` that never returned, so the deferred call was
|
||||
// unreachable for the life of the process.
|
||||
//
|
||||
// It now goes through the supervisor, which is what production does and what
|
||||
// owns the shutdown callback since the lease landed (#915): a scheduler stops
|
||||
// either because the process is going down or because this instance lost the
|
||||
// lease, and only the supervisor can tell those apart.
|
||||
//
|
||||
// There is one test rather than several because BeforeExit closes to further
|
||||
// registration once it has run: a second RunShutdown in this binary would find
|
||||
// an empty registry and pass while proving nothing.
|
||||
func TestTheSchedulerIsStoppedOnTheWayOut(t *testing.T) {
|
||||
// registration once it has run: a second RunShutdown in this binary would
|
||||
// find an empty registry and pass while proving nothing. The lease-release
|
||||
// assertion is folded in here for the same reason.
|
||||
func TestTheSchedulerIsStoppedAndTheLeaseHandedBackOnTheWayOut(t *testing.T) {
|
||||
const tenant = "*"
|
||||
|
||||
db := leaseDB(t)
|
||||
var ticks atomic.Int64
|
||||
|
||||
c := cronjob.NewWithSeconds()
|
||||
if _, err := c.AddFunc("* * * * * *", func() { ticks.Add(1) }); err != nil {
|
||||
t.Fatalf("AddFunc: %v", err)
|
||||
}
|
||||
sdk.Runtime.SetCrontabByTenant(tenant, c)
|
||||
|
||||
startCrontab(c)
|
||||
s := newSupervisor(tenant, db, "instance-under-test")
|
||||
s.start()
|
||||
|
||||
if !s.holdsLease() {
|
||||
t.Fatal("the supervisor did not take a free lease, so this test would prove nothing about giving it back")
|
||||
}
|
||||
|
||||
// It has to be running before stopping it can mean anything.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
@@ -49,4 +68,33 @@ func TestTheSchedulerIsStoppedOnTheWayOut(t *testing.T) {
|
||||
if n := ticks.Load() - at; n > 0 {
|
||||
t.Errorf("the job fired %d more times after shutdown: the scheduler is still running", n)
|
||||
}
|
||||
|
||||
// And the lease is free, so a successor takes it immediately instead of
|
||||
// waiting out a TTL held by a process that has exited.
|
||||
var row models2.SysJobLease
|
||||
if err := db.Where("name = ?", models2.SchedulerLeaseName).First(&row).Error; err != nil {
|
||||
t.Fatalf("reading the lease row: %v", err)
|
||||
}
|
||||
if row.Owner != "" {
|
||||
t.Errorf("the lease is still owned by %q after shutdown; a successor would wait out the TTL", row.Owner)
|
||||
}
|
||||
}
|
||||
|
||||
// leaseDB is a database with the two tables jobs.setup touches and one free
|
||||
// lease row, which is the shape migration 1786700009000 leaves behind.
|
||||
func leaseDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(glebarez.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("opening sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models2.SysJob{}, &models2.SysJobLease{}); err != nil {
|
||||
t.Fatalf("migrating: %v", err)
|
||||
}
|
||||
row := models2.SysJobLease{Name: models2.SchedulerLeaseName}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seeding the lease row: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
models2 "go-admin/app/jobs/models"
|
||||
)
|
||||
|
||||
// nowExprMs is the dialect's expression for the current time as
|
||||
// milliseconds since the Unix epoch.
|
||||
//
|
||||
// The lease compares one instance's idea of "expired" against another
|
||||
// instance's idea of "still mine", so both have to come from the same clock.
|
||||
// Two processes whose wall clocks differ by more than the lease TTL would
|
||||
// otherwise both hold it and both schedule - the exact situation the lease
|
||||
// exists to prevent, and it would look like it was working, because each
|
||||
// instance's own arithmetic is self-consistent.
|
||||
//
|
||||
// Milliseconds rather than a timestamp, because a timestamp does not survive
|
||||
// the trip through a driver unchanged. MySQL's UTC_TIMESTAMP read over
|
||||
// go-admin's own `parseTime=True&loc=Local` DSN arrives labelled as local
|
||||
// time: on a UTC+8 host every lease is eight hours out, and a test that only
|
||||
// checked the lease logic against itself passes anyway. An epoch integer has
|
||||
// no timezone for a driver to apply.
|
||||
func nowExprMs(dialect string) (string, error) {
|
||||
switch dialect {
|
||||
case "mysql":
|
||||
// UNIX_TIMESTAMP reads its argument in the session timezone and
|
||||
// NOW(3) is in the session timezone, so the two cancel and the
|
||||
// result is the absolute epoch regardless of what that zone is.
|
||||
return "CAST(ROUND(UNIX_TIMESTAMP(NOW(3)) * 1000) AS SIGNED)", nil
|
||||
case "postgres":
|
||||
return "CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)", nil
|
||||
case "sqlite":
|
||||
// julianday is the portable millisecond clock here: strftime('%s')
|
||||
// truncates to the second, and unixepoch('now','subsec') needs
|
||||
// SQLite 3.42.
|
||||
return "CAST((julianday('now') - 2440587.5) * 86400000.0 AS INTEGER)", nil
|
||||
case "sqlserver":
|
||||
return "DATEDIFF_BIG(millisecond, '1970-01-01T00:00:00', SYSUTCDATETIME())", nil
|
||||
}
|
||||
return "", fmt.Errorf("no epoch-milliseconds expression for dialect %q", dialect)
|
||||
}
|
||||
|
||||
// dbNowMs reads the clock from the database rather than from this process.
|
||||
//
|
||||
// The read and the UPDATE that uses it are two statements, so the value is
|
||||
// already slightly stale by the time it is compared - and that is the safe
|
||||
// direction in both places it is used:
|
||||
//
|
||||
// - as the expiry cutoff, a stale-old now makes this instance *less*
|
||||
// likely to decide another instance's lease has expired;
|
||||
// - as the basis for a new expiry, it makes this instance's own lease
|
||||
// expire sooner, so it renews sooner.
|
||||
//
|
||||
// Neither error makes two instances hold the lease at once.
|
||||
//
|
||||
// Zero is rejected rather than returned. It is what a failed conversion
|
||||
// looks like, it is before every expiry there will ever be, and an
|
||||
// implementation that passed it on would read every lease as expired, hand
|
||||
// it to every instance, and restore the defect this lease fixes with a lease
|
||||
// table sitting on top of it.
|
||||
func dbNowMs(db *gorm.DB) (int64, error) {
|
||||
expr, err := nowExprMs(db.Dialector.Name())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var ms int64
|
||||
if err := db.Raw("SELECT " + expr).Row().Scan(&ms); err != nil {
|
||||
return 0, fmt.Errorf("reading the database clock: %w", err)
|
||||
}
|
||||
if ms <= 0 {
|
||||
return 0, fmt.Errorf("the database clock read as %d from %q", ms, expr)
|
||||
}
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
// newOwnerID identifies this process in the lease row.
|
||||
//
|
||||
// Hostname and pid make a log line answer "which one is it" without a lookup;
|
||||
// the random suffix is what actually makes it unique, because a container
|
||||
// restarted under the same name can come back with the same hostname and the
|
||||
// same pid 1.
|
||||
func newOwnerID() string {
|
||||
host, err := os.Hostname()
|
||||
if err != nil || host == "" {
|
||||
host = "unknown"
|
||||
}
|
||||
return fmt.Sprintf("%s-%d-%s", host, os.Getpid(), uuid.New().String()[:8])
|
||||
}
|
||||
|
||||
// lease is one instance's claim on scheduling one database's jobs.
|
||||
type lease struct {
|
||||
db *gorm.DB
|
||||
owner string
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// acquire takes the lease or renews one this instance already holds, and
|
||||
// reports whether this instance holds it when it returns.
|
||||
//
|
||||
// Renewal is tried first and is scoped to this owner, so it cannot take a
|
||||
// lease another instance has meanwhile claimed. Only if that matches nothing
|
||||
// does it try to take an expired one. Both are single UPDATE statements
|
||||
// decided by RowsAffected: the database, not this process, arbitrates
|
||||
// between two instances running this at the same moment.
|
||||
//
|
||||
// There is no insert path. The migration seeds the row, so a missing row is
|
||||
// a broken installation rather than a state to recover from - and it is
|
||||
// reported as one, instead of being papered over by an insert that two
|
||||
// instances would race to win.
|
||||
func (l *lease) acquire() (bool, error) {
|
||||
nowMs, err := dbNowMs(l.db)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
expiresMs := nowMs + l.ttl.Milliseconds()
|
||||
|
||||
renewed := l.db.Model(&models2.SysJobLease{}).
|
||||
Where("name = ? AND owner = ?", models2.SchedulerLeaseName, l.owner).
|
||||
Update("expires_at_ms", expiresMs)
|
||||
if renewed.Error != nil {
|
||||
return false, fmt.Errorf("renewing the scheduler lease: %w", renewed.Error)
|
||||
}
|
||||
if renewed.RowsAffected > 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
taken := l.db.Model(&models2.SysJobLease{}).
|
||||
Where("name = ? AND expires_at_ms <= ?", models2.SchedulerLeaseName, nowMs).
|
||||
Updates(map[string]any{
|
||||
"owner": l.owner,
|
||||
"acquired_at_ms": nowMs,
|
||||
"expires_at_ms": expiresMs,
|
||||
})
|
||||
if taken.Error != nil {
|
||||
return false, fmt.Errorf("taking the scheduler lease: %w", taken.Error)
|
||||
}
|
||||
if taken.RowsAffected > 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Neither statement matched. Either another instance holds an
|
||||
// unexpired lease - the ordinary case, and not an error - or the row
|
||||
// the migration seeds is gone, which is, and which would otherwise
|
||||
// present as jobs silently never running anywhere.
|
||||
var rows int64
|
||||
if err := l.db.Model(&models2.SysJobLease{}).
|
||||
Where("name = ?", models2.SchedulerLeaseName).
|
||||
Count(&rows).Error; err != nil {
|
||||
return false, fmt.Errorf("checking for the scheduler lease row: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return false, fmt.Errorf("the %q lease row is missing from %s; run the migrations",
|
||||
models2.SchedulerLeaseName, (&models2.SysJobLease{}).TableName())
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// release hands the lease back so a successor can take it now instead of
|
||||
// waiting out the TTL. It is scoped to this owner: an instance that already
|
||||
// lost the lease must not clear the row its successor is holding.
|
||||
func (l *lease) release() error {
|
||||
res := l.db.Model(&models2.SysJobLease{}).
|
||||
Where("name = ? AND owner = ?", models2.SchedulerLeaseName, l.owner).
|
||||
Updates(map[string]any{"owner": "", "expires_at_ms": 0})
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("releasing the scheduler lease: %w", res.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
glebarez "github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlserver"
|
||||
"gorm.io/gorm"
|
||||
|
||||
models2 "go-admin/app/jobs/models"
|
||||
)
|
||||
|
||||
// The lease is the one thing in this package whose correctness is a property
|
||||
// of the database rather than of this process, so these run against every
|
||||
// dialect that can be reached. SQLite always; the others when their DSN is
|
||||
// set, and they must be set in CI - a suite that quietly skipped them would
|
||||
// report success for a lease that cannot be taken at all on the dialect most
|
||||
// installations actually run.
|
||||
const (
|
||||
mysqlDSNEnv = "GO_ADMIN_TEST_MYSQL_DSN"
|
||||
postgresDSNEnv = "GO_ADMIN_TEST_POSTGRES_DSN"
|
||||
sqlserverDSNEnv = "GO_ADMIN_TEST_SQLSERVER_DSN"
|
||||
)
|
||||
|
||||
type dialectDB struct {
|
||||
name string
|
||||
open func(string) gorm.Dialector
|
||||
env string
|
||||
}
|
||||
|
||||
var optionalDialects = []dialectDB{
|
||||
{"mysql", func(dsn string) gorm.Dialector { return mysql.Open(dsn) }, mysqlDSNEnv},
|
||||
{"postgres", func(dsn string) gorm.Dialector { return postgres.Open(dsn) }, postgresDSNEnv},
|
||||
{"sqlserver", func(dsn string) gorm.Dialector { return sqlserver.Open(dsn) }, sqlserverDSNEnv},
|
||||
}
|
||||
|
||||
// eachDialect runs body against SQLite and against every optional dialect
|
||||
// whose DSN is set.
|
||||
func eachDialect(t *testing.T, body func(t *testing.T, db *gorm.DB)) {
|
||||
t.Helper()
|
||||
|
||||
t.Run("sqlite", func(t *testing.T) {
|
||||
db, err := gorm.Open(glebarez.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("opening sqlite: %v", err)
|
||||
}
|
||||
body(t, seedLeaseTable(t, db))
|
||||
})
|
||||
|
||||
for _, d := range optionalDialects {
|
||||
t.Run(d.name, func(t *testing.T) {
|
||||
dsn := os.Getenv(d.env)
|
||||
if dsn == "" {
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Fatalf("%s is not set while CI is: the lease must not go untested on %s", d.env, d.name)
|
||||
}
|
||||
t.Skipf("%s is not set; skipping %s", d.env, d.name)
|
||||
}
|
||||
db, err := gorm.Open(d.open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("connecting to %s: %v", d.env, err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatalf("sql.DB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
body(t, seedLeaseTable(t, db))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// seedLeaseTable builds the shape 1786700009000 leaves behind: the table,
|
||||
// and exactly one free row.
|
||||
func seedLeaseTable(t *testing.T, db *gorm.DB) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
if err := db.Migrator().DropTable(&models2.SysJobLease{}); err != nil {
|
||||
t.Fatalf("dropping sys_job_lease: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models2.SysJobLease{}); err != nil {
|
||||
t.Fatalf("creating sys_job_lease: %v", err)
|
||||
}
|
||||
row := models2.SysJobLease{Name: models2.SchedulerLeaseName, AcquiredAtMs: 0, ExpiresAtMs: 0}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seeding the lease row: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestTheDatabaseClockIsReadableAndIsNotTheZeroTime(t *testing.T) {
|
||||
eachDialect(t, func(t *testing.T, db *gorm.DB) {
|
||||
nowMs, err := dbNowMs(db)
|
||||
if err != nil {
|
||||
t.Fatalf("dbNowMs: %v", err)
|
||||
}
|
||||
if nowMs <= 0 {
|
||||
t.Fatal("the database clock read as zero, which would read every lease as expired")
|
||||
}
|
||||
// Not an assertion about either clock's accuracy - a container's
|
||||
// clock and this one can drift. An hour is far wider than drift
|
||||
// and far narrower than a timezone offset, which is the mistake
|
||||
// this catches: reading MySQL's UTC_TIMESTAMP over a loc=Local
|
||||
// DSN lands exactly one zone offset away and is invisible to
|
||||
// every assertion that only compares the lease against itself.
|
||||
drift := time.Duration(time.Now().UnixMilli()-nowMs) * time.Millisecond
|
||||
if drift > time.Hour || drift < -time.Hour {
|
||||
t.Errorf("the database clock is %v away from this process's; a timezone mistake looks exactly like this", drift)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOnlyOneOfTwoInstancesTakesTheLease(t *testing.T) {
|
||||
eachDialect(t, func(t *testing.T, db *gorm.DB) {
|
||||
a := &lease{db: db, owner: "instance-a", ttl: time.Minute}
|
||||
b := &lease{db: db, owner: "instance-b", ttl: time.Minute}
|
||||
|
||||
heldA, err := a.acquire()
|
||||
if err != nil {
|
||||
t.Fatalf("a.acquire: %v", err)
|
||||
}
|
||||
if !heldA {
|
||||
t.Fatal("the first instance did not take a free lease")
|
||||
}
|
||||
|
||||
heldB, err := b.acquire()
|
||||
if err != nil {
|
||||
t.Fatalf("b.acquire: %v", err)
|
||||
}
|
||||
if heldB {
|
||||
t.Error("the second instance took a lease the first one holds: both would schedule")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestTheHolderRenewsAndTheOtherStillCannotTakeIt(t *testing.T) {
|
||||
eachDialect(t, func(t *testing.T, db *gorm.DB) {
|
||||
a := &lease{db: db, owner: "instance-a", ttl: time.Minute}
|
||||
b := &lease{db: db, owner: "instance-b", ttl: time.Minute}
|
||||
|
||||
if held, err := a.acquire(); err != nil || !held {
|
||||
t.Fatalf("a.acquire: held=%v err=%v", held, err)
|
||||
}
|
||||
before := readLease(t, db)
|
||||
|
||||
if held, err := a.acquire(); err != nil || !held {
|
||||
t.Fatalf("a renewing: held=%v err=%v", held, err)
|
||||
}
|
||||
after := readLease(t, db)
|
||||
|
||||
if after.ExpiresAtMs < before.ExpiresAtMs {
|
||||
t.Errorf("renewal moved the expiry backwards: %d then %d", before.ExpiresAtMs, after.ExpiresAtMs)
|
||||
}
|
||||
if after.AcquiredAtMs != before.AcquiredAtMs {
|
||||
t.Errorf("renewal moved acquired_at_ms (%d then %d); it must say when the lease was taken, not when it was last renewed",
|
||||
before.AcquiredAtMs, after.AcquiredAtMs)
|
||||
}
|
||||
if held, err := b.acquire(); err != nil || held {
|
||||
t.Errorf("the other instance took a renewed lease: held=%v err=%v", held, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnExpiredLeaseIsTakenOver(t *testing.T) {
|
||||
eachDialect(t, func(t *testing.T, db *gorm.DB) {
|
||||
a := &lease{db: db, owner: "instance-a", ttl: time.Minute}
|
||||
b := &lease{db: db, owner: "instance-b", ttl: time.Minute}
|
||||
|
||||
if held, err := a.acquire(); err != nil || !held {
|
||||
t.Fatalf("a.acquire: held=%v err=%v", held, err)
|
||||
}
|
||||
|
||||
// What a dead leader leaves behind: its row, unrenewed, past its
|
||||
// expiry. Forced rather than waited out, so the test does not
|
||||
// trade a second of sleep for the same assertion.
|
||||
expire(t, db)
|
||||
|
||||
if held, err := b.acquire(); err != nil || !held {
|
||||
t.Fatalf("the successor did not take an expired lease: held=%v err=%v", held, err)
|
||||
}
|
||||
if got := readLease(t, db).Owner; got != "instance-b" {
|
||||
t.Errorf("owner is %q after takeover, want instance-b", got)
|
||||
}
|
||||
|
||||
// And the instance that lost it must not get it back by renewing:
|
||||
// renewal is scoped to the owner column it no longer matches.
|
||||
if held, err := a.acquire(); err != nil || held {
|
||||
t.Errorf("the dead leader renewed a lease it had lost: held=%v err=%v", held, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleaseHandsTheLeaseOnWithoutWaitingOutTheTTL(t *testing.T) {
|
||||
eachDialect(t, func(t *testing.T, db *gorm.DB) {
|
||||
a := &lease{db: db, owner: "instance-a", ttl: time.Hour}
|
||||
b := &lease{db: db, owner: "instance-b", ttl: time.Minute}
|
||||
|
||||
if held, err := a.acquire(); err != nil || !held {
|
||||
t.Fatalf("a.acquire: held=%v err=%v", held, err)
|
||||
}
|
||||
if held, err := b.acquire(); err != nil || held {
|
||||
t.Fatalf("precondition: b must not hold it yet (held=%v err=%v)", held, err)
|
||||
}
|
||||
|
||||
if err := a.release(); err != nil {
|
||||
t.Fatalf("a.release: %v", err)
|
||||
}
|
||||
if held, err := b.acquire(); err != nil || !held {
|
||||
t.Errorf("a released a lease with an hour left and the successor still could not take it: held=%v err=%v", held, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReleasingALeaseSomebodyElseHoldsDoesNothing(t *testing.T) {
|
||||
eachDialect(t, func(t *testing.T, db *gorm.DB) {
|
||||
a := &lease{db: db, owner: "instance-a", ttl: time.Minute}
|
||||
stale := &lease{db: db, owner: "instance-gone", ttl: time.Minute}
|
||||
|
||||
if held, err := a.acquire(); err != nil || !held {
|
||||
t.Fatalf("a.acquire: held=%v err=%v", held, err)
|
||||
}
|
||||
if err := stale.release(); err != nil {
|
||||
t.Fatalf("stale.release: %v", err)
|
||||
}
|
||||
if got := readLease(t, db).Owner; got != "instance-a" {
|
||||
t.Errorf("owner is %q; an instance that already lost the lease cleared its successor's row", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAMissingLeaseRowIsReportedRatherThanSilentlyNeverScheduling(t *testing.T) {
|
||||
eachDialect(t, func(t *testing.T, db *gorm.DB) {
|
||||
if err := db.Where("name = ?", models2.SchedulerLeaseName).
|
||||
Delete(&models2.SysJobLease{}).Error; err != nil {
|
||||
t.Fatalf("deleting the lease row: %v", err)
|
||||
}
|
||||
a := &lease{db: db, owner: "instance-a", ttl: time.Minute}
|
||||
held, err := a.acquire()
|
||||
if held {
|
||||
t.Fatal("acquire reported the lease held with no row to hold")
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("a missing lease row was reported as an ordinary 'someone else holds it': jobs would never run anywhere and nothing would say why")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func readLease(t *testing.T, db *gorm.DB) models2.SysJobLease {
|
||||
t.Helper()
|
||||
var row models2.SysJobLease
|
||||
if err := db.Where("name = ?", models2.SchedulerLeaseName).First(&row).Error; err != nil {
|
||||
t.Fatalf("reading the lease row: %v", err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func expire(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Model(&models2.SysJobLease{}).
|
||||
Where("name = ?", models2.SchedulerLeaseName).
|
||||
Update("expires_at_ms", 0).Error; err != nil {
|
||||
t.Fatalf("expiring the lease: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package models
|
||||
|
||||
// SchedulerLeaseName is the name of the one lease row per database.
|
||||
//
|
||||
// One row, not one per tenant: a tenant is a separate database with its own
|
||||
// sys_job table and its own scheduler, so the row that decides who schedules
|
||||
// it lives in that database alongside the jobs it governs.
|
||||
const SchedulerLeaseName = "scheduler"
|
||||
|
||||
// SysJobLease is the scheduler's single-writer lease over one database.
|
||||
//
|
||||
// app/jobs registers every enabled job into an in-process cron.Cron and keeps
|
||||
// each job's scheduler handle in sys_job.entry_id. The scheduler is per
|
||||
// process and entry_id is one shared column, so a second instance pointed at
|
||||
// the same database does not divide the work - it overwrites it, and nothing
|
||||
// logs that it did (issue #915). Only the holder of this lease calls
|
||||
// jobs.Setup, which keeps the scheduler single-writer while the HTTP side
|
||||
// still scales.
|
||||
//
|
||||
// It deliberately embeds neither models.ModelTime nor models.ControlBy. A
|
||||
// lease is machine state, not a record a person creates, edits or
|
||||
// soft-deletes: there is no author to attribute it to, and a deleted-but-
|
||||
// present lease row would be a row that both does and does not hold the
|
||||
// scheduler.
|
||||
type SysJobLease struct {
|
||||
// Name is the lease being held. The migration seeds exactly one row,
|
||||
// SchedulerLeaseName, and the runtime only ever updates it - there is
|
||||
// no insert path, so two instances starting at once cannot race to
|
||||
// create the row they are both trying to claim.
|
||||
Name string `json:"name" gorm:"type:varchar(64);primaryKey"`
|
||||
|
||||
// Owner identifies the process that holds the lease. Empty means the
|
||||
// lease is free, which is what the migration seeds.
|
||||
Owner string `json:"owner" gorm:"type:varchar(191);not null"`
|
||||
|
||||
// AcquiredAtMs is when the current owner took the lease, not when it
|
||||
// last renewed: a leader that has held it for an hour and one that took
|
||||
// over a second ago are different situations, and only this column
|
||||
// tells them apart. Renewal moves ExpiresAtMs and leaves this alone.
|
||||
AcquiredAtMs int64 `json:"acquiredAtMs" gorm:"column:acquired_at_ms;not null"`
|
||||
|
||||
// ExpiresAtMs is when another instance may take the lease.
|
||||
//
|
||||
// Milliseconds since the Unix epoch, in a BIGINT, rather than a
|
||||
// timestamp column. A timestamp crossing the driver boundary carries
|
||||
// timezone semantics that the driver applies on the way through: with
|
||||
// go-admin's own `parseTime=True&loc=Local` DSN, MySQL's UTC_TIMESTAMP
|
||||
// comes back labelled as local time, and a lease written in Asia/
|
||||
// Shanghai is then eight hours out - in whichever direction makes every
|
||||
// other instance's lease look expired. An integer has no timezone for
|
||||
// anything to apply, and the comparison that decides who schedules
|
||||
// becomes integer arithmetic that no DSN setting can reinterpret.
|
||||
ExpiresAtMs int64 `json:"expiresAtMs" gorm:"column:expires_at_ms;not null"`
|
||||
}
|
||||
|
||||
func (*SysJobLease) TableName() string {
|
||||
return "sys_job_lease"
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// The frame the leaked goroutines park in. Remove starts it, and it is the
|
||||
// only goroutine in this package that sends on a channel the caller may have
|
||||
// walked away from.
|
||||
const removeSenderFrame = "go-admin/app/jobs.Remove.func1"
|
||||
|
||||
// Remove hands the caller a channel it is free to abandon: RemoveJob stops
|
||||
// waiting after a second and returns a timeout error. The send therefore has
|
||||
// to complete with nobody receiving, or every stop that times out parks a
|
||||
// goroutine on it for the life of the process.
|
||||
//
|
||||
// The order matters. Counting parked goroutines straight after calling Remove
|
||||
// would pass while proving nothing, because the goroutine may not have reached
|
||||
// the send yet. So the entries are waited out first: an empty scheduler means
|
||||
// every goroutine is at or past its send, and only then is a survivor a leak.
|
||||
func TestRemoveLetsItsGoroutineFinishWithNobodyReceiving(t *testing.T) {
|
||||
const jobs = 20
|
||||
|
||||
c := cron.New()
|
||||
ids := make([]cron.EntryID, 0, jobs)
|
||||
for i := 0; i < jobs; i++ {
|
||||
id, err := c.AddFunc("@every 1h", func() {})
|
||||
if err != nil {
|
||||
t.Fatalf("AddFunc: %v", err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
// The returned channel is dropped on purpose: this is what a caller
|
||||
// that has already timed out leaves behind.
|
||||
_ = Remove(c, int(id))
|
||||
}
|
||||
|
||||
if err := waitFor(3*time.Second, func() bool { return len(c.Entries()) == 0 }); err != nil {
|
||||
t.Fatalf("the scheduler still holds %d entries, so the goroutines never reached their send "+
|
||||
"and this test cannot show anything", len(c.Entries()))
|
||||
}
|
||||
|
||||
if err := waitFor(3*time.Second, func() bool { return parkedInRemove() == 0 }); err != nil {
|
||||
t.Errorf("%d of %d goroutines are still parked sending on an abandoned channel:\n%s",
|
||||
parkedInRemove(), jobs, oneParkedStack())
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(d time.Duration, done func() bool) error {
|
||||
deadline := time.Now().Add(d)
|
||||
for {
|
||||
if done() {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return errTimeout
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
var errTimeout = timeoutError{}
|
||||
|
||||
type timeoutError struct{}
|
||||
|
||||
func (timeoutError) Error() string { return "timed out" }
|
||||
|
||||
func parkedInRemove() int {
|
||||
return strings.Count(goroutineDump(), removeSenderFrame)
|
||||
}
|
||||
|
||||
func oneParkedStack() string {
|
||||
for _, block := range strings.Split(goroutineDump(), "\n\n") {
|
||||
if strings.Contains(block, removeSenderFrame) {
|
||||
return block
|
||||
}
|
||||
}
|
||||
return "(none)"
|
||||
}
|
||||
|
||||
func goroutineDump() string {
|
||||
buf := make([]byte, 1<<20)
|
||||
for {
|
||||
n := runtime.Stack(buf, true)
|
||||
if n < len(buf) {
|
||||
return string(buf[:n])
|
||||
}
|
||||
buf = make([]byte, 2*len(buf))
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func (e *SysJob) RemoveJob(c *dto.GeneralDelDto) error {
|
||||
}
|
||||
case <-time.After(time.Second * 1):
|
||||
e.Msg = "操作超时!"
|
||||
return nil
|
||||
return errors.New(e.Msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
coreservice "github.com/go-admin-team/go-admin-core/v2/sdk/service"
|
||||
"github.com/robfig/cron/v3"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/jobs/models"
|
||||
"go-admin/common/dto"
|
||||
)
|
||||
|
||||
type blockedSchedule struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (s blockedSchedule) Next(now time.Time) time.Time {
|
||||
close(s.started)
|
||||
<-s.release
|
||||
return now.Add(time.Hour)
|
||||
}
|
||||
|
||||
func TestRemoveJob(t *testing.T) {
|
||||
for _, blocked := range []bool{false, true} {
|
||||
name := "success"
|
||||
if blocked {
|
||||
name = "timeout"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := db.AutoMigrate(&models.SysJob{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := cron.New()
|
||||
schedule := blockedSchedule{make(chan struct{}), make(chan struct{})}
|
||||
entryID := c.Schedule(schedule, cron.FuncJob(func() {}))
|
||||
if blocked {
|
||||
c.Start()
|
||||
t.Cleanup(func() {
|
||||
close(schedule.release)
|
||||
<-c.Stop().Done()
|
||||
})
|
||||
select {
|
||||
case <-schedule.started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("scheduler did not start")
|
||||
}
|
||||
}
|
||||
job := models.SysJob{EntryId: int(entryID)}
|
||||
if err := db.Create(&job).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := SysJob{Service: coreservice.Service{Orm: db}, Cron: c}
|
||||
err = s.RemoveJob(&dto.GeneralDelDto{Id: job.JobId})
|
||||
if blocked {
|
||||
if err == nil || err.Error() != "操作超时!" {
|
||||
t.Errorf("RemoveJob error = %v, want timeout error", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved models.SysJob
|
||||
if err := db.First(&saved, job.JobId).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantEntryID := 0
|
||||
if blocked {
|
||||
wantEntryID = int(entryID)
|
||||
}
|
||||
if saved.EntryId != wantEntryID {
|
||||
t.Errorf("entry_id = %d, want %d", saved.EntryId, wantEntryID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/go-admin-team/go-admin-core/v2/logger"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// leaseTTL is how long a lease stays valid without being renewed, and
|
||||
// leaseHeartbeat is how often the holder renews it.
|
||||
//
|
||||
// The gap between them is the point: at a third of the TTL, two consecutive
|
||||
// renewals can fail - a restarting database, a paused container - and the
|
||||
// third still lands before anything else may take the lease. Making them
|
||||
// equal would hand the scheduler to another instance on the first missed
|
||||
// beat.
|
||||
//
|
||||
// The TTL is also the longest the jobs can be stopped everywhere: an
|
||||
// instance killed without running its shutdown leaves its lease behind, and
|
||||
// the successor waits this long before taking it.
|
||||
const (
|
||||
leaseTTL = 30 * time.Second
|
||||
leaseHeartbeat = 10 * time.Second
|
||||
)
|
||||
|
||||
// supervisor keeps one tenant's scheduler in step with one lease.
|
||||
//
|
||||
// It exists because holding the lease is not a decision made once at
|
||||
// startup. An instance that never gets the lease has to keep asking, or the
|
||||
// death of the current holder would stop the jobs until somebody restarted a
|
||||
// process by hand; and an instance that holds it has to stop scheduling the
|
||||
// moment it can no longer prove it still does, or a network partition turns
|
||||
// into the two-schedulers-at-once defect (#915) that the lease exists to
|
||||
// prevent.
|
||||
type supervisor struct {
|
||||
key string
|
||||
db *gorm.DB
|
||||
lease *lease
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
// lastRenew is when this instance last proved it holds the lease. It
|
||||
// is compared only against this process's own later readings, never
|
||||
// against another instance's, so the monotonic clock is the right one
|
||||
// here - the reason the lease itself reads the database's clock does
|
||||
// not apply to measuring how long ago something happened locally.
|
||||
lastRenew time.Time
|
||||
|
||||
stop chan struct{}
|
||||
stopOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newSupervisor(key string, db *gorm.DB, owner string) *supervisor {
|
||||
return &supervisor{
|
||||
key: key,
|
||||
db: db,
|
||||
lease: &lease{db: db, owner: owner, ttl: leaseTTL},
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// start takes the lease if it is free, schedules this tenant's jobs if it
|
||||
// got it, and then keeps both facts true for the life of the process.
|
||||
//
|
||||
// The first attempt is synchronous so that a single-instance deployment -
|
||||
// which is nearly all of them - has its jobs registered by the time Setup
|
||||
// returns, exactly as it did before there was a lease.
|
||||
func (s *supervisor) start() {
|
||||
s.tick()
|
||||
|
||||
go s.heartbeat()
|
||||
|
||||
sdk.Runtime.SetShutdown(func(ctx context.Context) {
|
||||
s.shutdown(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *supervisor) heartbeat() {
|
||||
defer close(s.done)
|
||||
|
||||
t := time.NewTicker(leaseHeartbeat)
|
||||
defer t.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
s.tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tick asks for the lease and makes the scheduler match the answer.
|
||||
func (s *supervisor) tick() {
|
||||
held, err := s.lease.acquire()
|
||||
if err != nil {
|
||||
// Not knowing is not the same as having lost it. The lease is
|
||||
// still ours until it expires, so the scheduler keeps running
|
||||
// and this instance keeps trying - a database that is briefly
|
||||
// unreachable must not stop the jobs, and must not hand them to
|
||||
// anyone else either, because nobody else can reach it to take
|
||||
// the lease.
|
||||
log.Errorf("[Job] scheduler lease for %s: %v", s.key, err)
|
||||
if s.heldFor() > leaseTTL {
|
||||
log.Errorf("[Job] scheduler lease for %s has not been renewed in %v; stopping the scheduler before anything else takes it",
|
||||
s.key, leaseTTL)
|
||||
s.stopScheduling()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !held {
|
||||
s.stopScheduling()
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.lastRenew = time.Now()
|
||||
already := s.running
|
||||
s.mu.Unlock()
|
||||
|
||||
if !already {
|
||||
s.startScheduling()
|
||||
}
|
||||
}
|
||||
|
||||
// heldFor reports how long it has been since this instance last proved it
|
||||
// holds the lease. A zero lastRenew means it never has, which is not a lease
|
||||
// that has gone stale.
|
||||
func (s *supervisor) heldFor() time.Duration {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.lastRenew.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return time.Since(s.lastRenew)
|
||||
}
|
||||
|
||||
func (s *supervisor) startScheduling() {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.running = true
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Infof("[Job] holding the scheduler lease for %s; registering its jobs", s.key)
|
||||
setup(s.key, s.db)
|
||||
}
|
||||
|
||||
// stopScheduling stops this tenant's scheduler and puts a fresh one in its
|
||||
// place.
|
||||
//
|
||||
// Fresh, rather than reusing the stopped one, because taking the lease back
|
||||
// runs setup again and setup adds every enabled job to whatever scheduler is
|
||||
// registered. Reusing it would leave the previous registration in place and
|
||||
// fire every job twice - the symptom this whole change is here to remove,
|
||||
// reintroduced one layer down.
|
||||
func (s *supervisor) stopScheduling() {
|
||||
s.mu.Lock()
|
||||
if !s.running {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Infof("[Job] no longer holding the scheduler lease for %s; stopping its jobs", s.key)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), leaseHeartbeat)
|
||||
defer cancel()
|
||||
stopCrontab(ctx, s.key)
|
||||
sdk.Runtime.SetCrontabByTenant(s.key, cronjob.NewWithSeconds())
|
||||
}
|
||||
|
||||
// shutdown stops the heartbeat, stops the scheduler and hands the lease back
|
||||
// so a successor can take it now rather than waiting out the TTL.
|
||||
func (s *supervisor) shutdown(ctx context.Context) {
|
||||
s.stopOnce.Do(func() { close(s.stop) })
|
||||
select {
|
||||
case <-s.done:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
wasRunning := s.running
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
|
||||
if wasRunning {
|
||||
stopCrontab(ctx, s.key)
|
||||
if err := s.lease.release(); err != nil {
|
||||
log.Errorf("[Job] releasing the scheduler lease for %s: %v", s.key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// holdsLease reports whether this instance is currently scheduling. It exists
|
||||
// for the tests: everything else acts on the answer inside tick.
|
||||
func (s *supervisor) holdsLease() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.running
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg/cronjob"
|
||||
|
||||
models2 "go-admin/app/jobs/models"
|
||||
)
|
||||
|
||||
// An instance that starts while another one holds the lease must not
|
||||
// register the jobs. This is the whole point: every instance registering the
|
||||
// whole enabled list into its own scheduler is what made one job fire once
|
||||
// per instance (#915).
|
||||
func TestASecondInstanceDoesNotScheduleWhileTheFirstHoldsTheLease(t *testing.T) {
|
||||
const tenant = "second-instance"
|
||||
db := leaseDB(t)
|
||||
sdk.Runtime.SetCrontabByTenant(tenant, cronjob.NewWithSeconds())
|
||||
|
||||
first := newSupervisor(tenant, db, "instance-a")
|
||||
first.tick()
|
||||
if !first.holdsLease() {
|
||||
t.Fatal("the first instance did not take a free lease")
|
||||
}
|
||||
|
||||
second := newSupervisor(tenant, db, "instance-b")
|
||||
second.tick()
|
||||
if second.holdsLease() {
|
||||
t.Error("a second instance scheduled while the first holds the lease: the job would fire twice per tick")
|
||||
}
|
||||
}
|
||||
|
||||
// Losing the lease has to stop the scheduler, not merely stop it from being
|
||||
// taken again. A holder that keeps scheduling after its lease has gone to
|
||||
// somebody else is two schedulers at once - the defect the lease exists to
|
||||
// prevent, reached from the other direction.
|
||||
func TestTheSupervisorStopsSchedulingWhenItLosesTheLease(t *testing.T) {
|
||||
const tenant = "loses-lease"
|
||||
db := leaseDB(t)
|
||||
sdk.Runtime.SetCrontabByTenant(tenant, cronjob.NewWithSeconds())
|
||||
|
||||
holder := newSupervisor(tenant, db, "instance-a")
|
||||
holder.tick()
|
||||
if !holder.holdsLease() {
|
||||
t.Fatal("the supervisor did not take a free lease, so losing it cannot be observed")
|
||||
}
|
||||
|
||||
// What a partition looks like from the database's side: the lease
|
||||
// lapsed and somebody else took it while this instance was away.
|
||||
expire(t, db)
|
||||
successor := &lease{db: db, owner: "instance-b", ttl: time.Minute}
|
||||
if held, err := successor.acquire(); err != nil || !held {
|
||||
t.Fatalf("the successor could not take the expired lease: held=%v err=%v", held, err)
|
||||
}
|
||||
|
||||
holder.tick()
|
||||
|
||||
if holder.holdsLease() {
|
||||
t.Error("the supervisor kept scheduling after the lease went to another instance")
|
||||
}
|
||||
if got := readLease(t, db).Owner; got != "instance-b" {
|
||||
t.Errorf("owner is %q; the instance that lost the lease wrote over its successor", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A database that cannot be reached is not the same as a lease that has been
|
||||
// lost. Stopping on the first failed renewal would stop the jobs every time
|
||||
// the database blinked - and hand them to nobody, because no other instance
|
||||
// can reach it to take the lease either.
|
||||
func TestABrieflyUnreachableDatabaseDoesNotStopTheScheduler(t *testing.T) {
|
||||
const tenant = "db-blip"
|
||||
db := leaseDB(t)
|
||||
sdk.Runtime.SetCrontabByTenant(tenant, cronjob.NewWithSeconds())
|
||||
|
||||
s := newSupervisor(tenant, db, "instance-a")
|
||||
s.tick()
|
||||
if !s.holdsLease() {
|
||||
t.Fatal("the supervisor did not take a free lease")
|
||||
}
|
||||
|
||||
// The table going missing is how an unreachable database presents to
|
||||
// acquire: every statement against it returns an error.
|
||||
if err := db.Migrator().DropTable(&models2.SysJobLease{}); err != nil {
|
||||
t.Fatalf("dropping the lease table: %v", err)
|
||||
}
|
||||
|
||||
s.tick()
|
||||
|
||||
if !s.holdsLease() {
|
||||
t.Error("one failed renewal stopped the scheduler; the lease had not expired yet and nobody else could have taken it")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
)
|
||||
|
||||
// columnLengthPattern pulls the first parenthesized integer out of a MySQL
|
||||
// COLUMN_TYPE string - the "(255)" in "varchar(255)", the "(10" in
|
||||
// "decimal(10,2)". Works regardless of trailing modifiers such as
|
||||
// "unsigned" or a charset clause, since it only looks for the first digits
|
||||
// after the first '('.
|
||||
var columnLengthPattern = regexp.MustCompile(`\((\d+)`)
|
||||
|
||||
// InferColumnWidth backs R2's fallback path: when a column's colWidth is
|
||||
// left at its 0 sentinel (unconfigured), this reads sys_columns.column_type
|
||||
// - MySQL's information_schema.COLUMNS.COLUMN_TYPE, which carries length,
|
||||
// e.g. "varchar(255)", "int(11)", "decimal(10,2)", "tinyint(1)" - and
|
||||
// returns a px width sized to fit inside go-admin-ui's ~580px text-column
|
||||
// budget for a 1280px viewport (its AGENTS.md "列宽" section).
|
||||
//
|
||||
// The judgment has to be columnType, not goType: sys_tables.go:323-338
|
||||
// gives every non-primary-key int/tinyint/bigint/decimal column goType
|
||||
// "string" (a bare substring match on "int" that also catches "tinyint"/
|
||||
// "bigint", intentional at import time but useless for telling a boolean
|
||||
// flag from a bigint), so goType alone cannot distinguish a switch column
|
||||
// from a price column from a name column. This is the same judgment call
|
||||
// API契约.md §1.1 made, reversing the PRD's original "GoType" reading of R2.
|
||||
// GoType is not consulted anywhere in this function, including for
|
||||
// datetime/timestamp columns - those are matched on columnType too.
|
||||
//
|
||||
// Exported and pure (string in, int out) so QA can pin an exact input/output
|
||||
// table against it directly (测试用例.md §2.5's own recommendation), rather
|
||||
// than only being able to assert "the rendered page happens not to overflow".
|
||||
func InferColumnWidth(columnType string) int {
|
||||
ct := strings.ToLower(strings.TrimSpace(columnType))
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(ct, "tinyint(1)"):
|
||||
// MySQL's own shape for a boolean/status flag - a tag or a switch,
|
||||
// not text, so it wants less room than a general numeric column.
|
||||
return 70
|
||||
|
||||
case strings.Contains(ct, "datetime"), strings.Contains(ct, "timestamp"),
|
||||
strings.Contains(ct, "date"), strings.Contains(ct, "time"):
|
||||
return 110
|
||||
|
||||
case strings.HasPrefix(ct, "tinyint"), strings.HasPrefix(ct, "smallint"),
|
||||
strings.HasPrefix(ct, "mediumint"), strings.HasPrefix(ct, "int"),
|
||||
strings.HasPrefix(ct, "bigint"), strings.HasPrefix(ct, "decimal"),
|
||||
strings.HasPrefix(ct, "float"), strings.HasPrefix(ct, "double"):
|
||||
// API契约.md §1.1: "decimal/bigint/int 类给数字型窄宽度" groups these
|
||||
// together rather than sizing each individually - none of them need
|
||||
// more than a handful of digits' worth of width.
|
||||
return 90
|
||||
|
||||
case strings.HasPrefix(ct, "varchar"), strings.HasPrefix(ct, "char"):
|
||||
return varcharWidth(columnLength(ct))
|
||||
|
||||
case strings.Contains(ct, "text"), strings.Contains(ct, "blob"):
|
||||
// longtext/mediumtext/text/blob: no declared length to size against,
|
||||
// and content here is free-form, so this errs wide rather than
|
||||
// guessing a number the actual content will not respect.
|
||||
return 260
|
||||
|
||||
default:
|
||||
// Unrecognized column_type (an enum, a json column, a driver this
|
||||
// codebase does not special-case, ...). Matches the flat fallback
|
||||
// vue.go.template already used for every non-datetime column before
|
||||
// this function existed, so a type this does not recognize is no
|
||||
// worse off than the old blanket default.
|
||||
return 120
|
||||
}
|
||||
}
|
||||
|
||||
// varcharWidth tiers a char/varchar column by its declared length. The
|
||||
// tiers are deliberately coarse - R2 only asks for "common tables land in
|
||||
// the 580px budget", not pixel-perfect sizing per character.
|
||||
func varcharWidth(n int) int {
|
||||
switch {
|
||||
case n <= 0:
|
||||
// Length did not parse (unexpected shape) - mid tier, not the
|
||||
// narrowest, since an un-lengthed varchar is unlikely to be a
|
||||
// short code column.
|
||||
return 150
|
||||
case n <= 10:
|
||||
return 90
|
||||
case n <= 20:
|
||||
return 110
|
||||
case n <= 50:
|
||||
return 150
|
||||
case n <= 100:
|
||||
return 200
|
||||
default:
|
||||
return 240
|
||||
}
|
||||
}
|
||||
|
||||
// columnLength extracts the first parenthesized integer, or 0 if the type
|
||||
// string does not have one (already-lowercased input expected).
|
||||
func columnLength(columnType string) int {
|
||||
m := columnLengthPattern.FindStringSubmatch(columnType)
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// applyInferredColumnWidths fills in InferColumnWidth's result for every
|
||||
// column still at the 0 "unconfigured" sentinel, in place, before the
|
||||
// template that reads .ColWidth runs. A column the user (or F6's config
|
||||
// page) already gave an explicit width is left untouched.
|
||||
func applyInferredColumnWidths(columns []tools.SysColumns) {
|
||||
for i := range columns {
|
||||
if columns[i].ColWidth == 0 {
|
||||
columns[i].ColWidth = InferColumnWidth(columns[i].ColumnType)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
)
|
||||
|
||||
// Input/output pins for InferColumnWidth, per 测试用例.md §2.5's own
|
||||
// recommendation ("QA 才能在阶段 4 补一张精确的输入→输出对照表断言, 而不是只测
|
||||
// 结果凑巧没溢出这种弱结论") - this is that table, kept next to the function
|
||||
// it pins rather than only living in a later QA-owned suite.
|
||||
func TestInferColumnWidth(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
columnType string
|
||||
want int
|
||||
}{
|
||||
{"boolean/status flag", "tinyint(1)", 70},
|
||||
{"boolean flag, case-insensitive", "TINYINT(1)", 70},
|
||||
{"datetime", "datetime", 110},
|
||||
{"timestamp", "timestamp", 110},
|
||||
{"date only", "date", 110},
|
||||
{"time only", "time", 110},
|
||||
|
||||
{"plain tinyint (not the (1) boolean shape)", "tinyint(4)", 90},
|
||||
{"smallint", "smallint(6)", 90},
|
||||
{"mediumint", "mediumint(9)", 90},
|
||||
{"int", "int(11)", 90},
|
||||
{"bigint", "bigint(20)", 90},
|
||||
{"decimal", "decimal(10,2)", 90},
|
||||
{"float", "float", 90},
|
||||
{"double", "double", 90},
|
||||
|
||||
{"varchar short code", "varchar(8)", 90},
|
||||
{"varchar at the 10 boundary", "varchar(10)", 90},
|
||||
{"varchar just past the 10 boundary", "varchar(11)", 110},
|
||||
{"varchar at the 20 boundary", "varchar(20)", 110},
|
||||
{"varchar mid length", "varchar(32)", 150},
|
||||
{"varchar at the 50 boundary", "varchar(50)", 150},
|
||||
{"varchar just past the 50 boundary", "varchar(51)", 200},
|
||||
{"varchar(255), the common default", "varchar(255)", 240},
|
||||
{"char, fixed-width", "char(2)", 90},
|
||||
{"varchar with no parsed length", "varchar", 150},
|
||||
|
||||
{"text, no length to size against", "text", 260},
|
||||
{"longtext", "longtext", 260},
|
||||
{"mediumtext", "mediumtext", 260},
|
||||
{"blob", "blob", 260},
|
||||
|
||||
{"unrecognized type falls back to the old flat default", "json", 120},
|
||||
{"empty column_type falls back to the old flat default", "", 120},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := InferColumnWidth(tc.columnType); got != tc.want {
|
||||
t.Errorf("InferColumnWidth(%q) = %d, want %d", tc.columnType, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyInferredColumnWidths(t *testing.T) {
|
||||
columns := []tools.SysColumns{
|
||||
{JsonField: "name", ColumnType: "varchar(64)", ColWidth: 0},
|
||||
{JsonField: "price", ColumnType: "decimal(10,2)", ColWidth: 300}, // already configured
|
||||
}
|
||||
|
||||
applyInferredColumnWidths(columns)
|
||||
|
||||
if columns[0].ColWidth == 0 {
|
||||
t.Error("unconfigured column: want an inferred non-zero width, still 0")
|
||||
}
|
||||
if want := InferColumnWidth("varchar(64)"); columns[0].ColWidth != want {
|
||||
t.Errorf("unconfigured column: want %d (InferColumnWidth's own answer), got %d", want, columns[0].ColWidth)
|
||||
}
|
||||
if columns[1].ColWidth != 300 {
|
||||
t.Errorf("already-configured column: want the user's 300 left untouched, got %d", columns[1].ColWidth)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,12 @@ import (
|
||||
"go-admin/app/other/models/tools"
|
||||
)
|
||||
|
||||
// emptyTableNameMsg is what the generator's endpoints answer with when the
|
||||
// request named no table. Declared once because the tests assert on it: spelled
|
||||
// out again at each site, a reworded message would leave them asserting on a
|
||||
// string the server no longer sends, and still passing.
|
||||
const emptyTableNameMsg = "table name cannot be empty!"
|
||||
|
||||
// GetDBColumnList 分页列表数据
|
||||
// @Summary 分页列表数据 / page list data
|
||||
// @Description 数据库表列分页列表 / database table column page list
|
||||
@@ -41,7 +47,7 @@ func (e Gen) GetDBColumnList(c *gin.Context) {
|
||||
}
|
||||
|
||||
data.TableName = c.Request.FormValue("tableName")
|
||||
pkg.Assert(data.TableName != "", "table name cannot be empty!", 500)
|
||||
pkg.Assert(data.TableName != "", emptyTableNameMsg, 500)
|
||||
result, count, err := data.GetPage(db, pageSize, pageIndex)
|
||||
if err != nil {
|
||||
log.Errorf("GetPage error, %s", err.Error())
|
||||
|
||||
@@ -16,17 +16,21 @@ import (
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
const emptyTableNameMsg = "table name cannot be empty!"
|
||||
|
||||
// bodyOf covers both the success and the CustomError shape: both carry msg.
|
||||
type bodyOf struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
// newColumnListEngine wires the handler the way the router does, including the
|
||||
// newEngine wires one generator handler the way the router does, including the
|
||||
// middleware that turns pkg.Assert's panic into a response.
|
||||
func newColumnListEngine(t *testing.T) *gin.Engine {
|
||||
//
|
||||
// The generator's queries target MySQL's information_schema and cannot run on
|
||||
// the sqlite connection behind them; the driver setting only has to select that
|
||||
// branch, since no statement here is expected to succeed. That makes this
|
||||
// serviceable for any handler in this package whose behaviour is decided before
|
||||
// the query goes out -- which is what these tests are about.
|
||||
func newEngine(t *testing.T, method, path string, h gin.HandlerFunc) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -35,26 +39,33 @@ func newColumnListEngine(t *testing.T) *gin.Engine {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
|
||||
// The query targets MySQL's information_schema; the driver setting only has
|
||||
// to select that branch, the statement itself is never expected to succeed.
|
||||
previous := config.DatabaseConfig.Driver
|
||||
config.DatabaseConfig.Driver = "mysql"
|
||||
t.Cleanup(func() { config.DatabaseConfig.Driver = previous })
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.CustomError)
|
||||
r.GET("/db/columns/page", func(c *gin.Context) {
|
||||
r.Handle(method, path, func(c *gin.Context) {
|
||||
c.Set("db", db)
|
||||
c.Set(pkg.LoggerKey, logger.NewHelper(logger.DefaultLogger))
|
||||
Gen{}.GetDBColumnList(c)
|
||||
h(c)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func columnListMsg(t *testing.T, r *gin.Engine, query string) bodyOf {
|
||||
func newColumnListEngine(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
return newEngine(t, http.MethodGet, "/db/columns/page", Gen{}.GetDBColumnList)
|
||||
}
|
||||
|
||||
// serveJSON runs one request through the engine and decodes the envelope every
|
||||
// handler here answers with. A body that will not decode fails the test rather
|
||||
// than being reported as a mismatched message, which reads as the handler
|
||||
// having answered something unexpected instead of not having answered at all.
|
||||
func serveJSON(t *testing.T, r *gin.Engine, req *http.Request) bodyOf {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/db/columns/page"+query, nil))
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var body bodyOf
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
@@ -63,6 +74,11 @@ func columnListMsg(t *testing.T, r *gin.Engine, query string) bodyOf {
|
||||
return body
|
||||
}
|
||||
|
||||
func columnListMsg(t *testing.T, r *gin.Engine, query string) bodyOf {
|
||||
t.Helper()
|
||||
return serveJSON(t, r, httptest.NewRequest(http.MethodGet, "/db/columns/page"+query, nil))
|
||||
}
|
||||
|
||||
func TestGetDBColumnList_AcceptsATableName(t *testing.T) {
|
||||
body := columnListMsg(t, newColumnListEngine(t), "?tableName=sys_user")
|
||||
if body.Msg == emptyTableNameMsg {
|
||||
|
||||
+104
-10
@@ -22,6 +22,29 @@ type Gen struct {
|
||||
api.Api
|
||||
}
|
||||
|
||||
// genLangFuncs backs the lang-zh/lang-en templates (PRD 010 F3/F9). The
|
||||
// generated files are TypeScript, and go-admin-ui's eslint config requires
|
||||
// single-quoted strings with no trailing comma (@stylistic/quotes,
|
||||
// @stylistic/comma-dangle: never) - text/template's builtin `printf "%q"`
|
||||
// only produces Go/JSON-style double-quoted output, so this supplies a
|
||||
// single-quote equivalent instead of leaning on the builtin.
|
||||
var genLangFuncs = template.FuncMap{
|
||||
"singleQuote": func(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `'`, `\'`, "\n", `\n`, "\r", `\r`)
|
||||
return "'" + r.Replace(s) + "'"
|
||||
},
|
||||
}
|
||||
|
||||
// parseGenTemplate is template.ParseFiles plus genLangFuncs, for the two
|
||||
// language-pack templates. template.New's name must match the file's base
|
||||
// name - ParseFiles reuses the template already registered under that name
|
||||
// instead of creating an unnamed second one, which is what makes Execute
|
||||
// find the parsed content afterwards.
|
||||
func parseGenTemplate(path string) (*template.Template, error) {
|
||||
base := path[strings.LastIndex(path, "/")+1:]
|
||||
return template.New(base).Funcs(genLangFuncs).ParseFiles(path)
|
||||
}
|
||||
|
||||
func (e Gen) Preview(c *gin.Context) {
|
||||
e.Context = c
|
||||
log := e.GetLogger()
|
||||
@@ -45,10 +68,10 @@ func (e Gen) Preview(c *gin.Context) {
|
||||
e.Error(500, err, fmt.Sprintf("api模版读取失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
t3, err := template.ParseFiles("template/v4/js.go.template")
|
||||
t3, err := template.ParseFiles("template/v4/ts.go.template")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("js模版读取失败!错误详情:%s", err.Error()))
|
||||
e.Error(500, err, fmt.Sprintf("ts模版读取失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
t4, err := template.ParseFiles("template/v4/vue.go.template")
|
||||
@@ -75,6 +98,22 @@ func (e Gen) Preview(c *gin.Context) {
|
||||
e.Error(500, err, fmt.Sprintf("service模版读取失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
// t8/t9 back F3/F9 (PRD 010): one language pack per locale, nested under
|
||||
// gen/{PackageName}/{BusinessName}.ts by NOActionsGen below so go-admin-ui's
|
||||
// gen-namespace.ts glob (`./*/*.ts` under each locale's gen/) picks them up.
|
||||
// See docs-prd/010-代码生成器前端模板迁移Vue3/API契约.md §2.3.
|
||||
t8, err := parseGenTemplate("template/v4/lang-zh.go.template")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("zh语言包模版读取失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
t9, err := parseGenTemplate("template/v4/lang-en.go.template")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("en语言包模版读取失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
db, err := pkg.GetOrm(c)
|
||||
if err != nil {
|
||||
@@ -83,7 +122,18 @@ func (e Gen) Preview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
tab, _ := table.Get(db,false)
|
||||
tab, _ := table.Get(db, false)
|
||||
// MLTBName (table_name with underscores turned to dashes) is a gorm:"-"
|
||||
// field - table.Get never fills it in, so every template that reads it
|
||||
// (the .vue/.ts import paths, e.g. "@/api/{PackageName}/{MLTBName}")
|
||||
// silently rendered it empty here. NOActionsGen has set this since it
|
||||
// existed (see below); Preview never did, which is why the two paths
|
||||
// are not interchangeable stand-ins for each other and should not be
|
||||
// assumed to be.
|
||||
tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1)
|
||||
// R2: infer a width for any column the config page left at colWidth's 0
|
||||
// sentinel, before vue.go.template reads .ColWidth - see column_width.go.
|
||||
applyInferredColumnWidths(tab.Columns)
|
||||
var b1 bytes.Buffer
|
||||
err = t1.Execute(&b1, tab)
|
||||
var b2 bytes.Buffer
|
||||
@@ -98,15 +148,21 @@ func (e Gen) Preview(c *gin.Context) {
|
||||
err = t6.Execute(&b6, tab)
|
||||
var b7 bytes.Buffer
|
||||
err = t7.Execute(&b7, tab)
|
||||
var b8 bytes.Buffer
|
||||
err = t8.Execute(&b8, tab)
|
||||
var b9 bytes.Buffer
|
||||
err = t9.Execute(&b9, tab)
|
||||
|
||||
mp := make(map[string]interface{})
|
||||
mp["template/model.go.template"] = b1.String()
|
||||
mp["template/api.go.template"] = b2.String()
|
||||
mp["template/js.go.template"] = b3.String()
|
||||
mp["template/api.ts.template"] = b3.String()
|
||||
mp["template/vue.go.template"] = b4.String()
|
||||
mp["template/router.go.template"] = b5.String()
|
||||
mp["template/dto.go.template"] = b6.String()
|
||||
mp["template/service.go.template"] = b7.String()
|
||||
mp["template/lang-zh.go.template"] = b8.String()
|
||||
mp["template/lang-en.go.template"] = b9.String()
|
||||
e.OK(mp, "")
|
||||
}
|
||||
|
||||
@@ -129,7 +185,7 @@ func (e Gen) GenCode(c *gin.Context) {
|
||||
}
|
||||
|
||||
table.TableId = id
|
||||
tab, _ := table.Get(db,false)
|
||||
tab, _ := table.Get(db, false)
|
||||
|
||||
e.NOActionsGen(c, tab)
|
||||
|
||||
@@ -155,7 +211,7 @@ func (e Gen) GenApiToFile(c *gin.Context) {
|
||||
}
|
||||
|
||||
table.TableId = id
|
||||
tab, _ := table.Get(db,false)
|
||||
tab, _ := table.Get(db, false)
|
||||
e.genApiToFile(c, tab)
|
||||
|
||||
e.OK("", "Code generated successfully!")
|
||||
@@ -165,6 +221,8 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
|
||||
e.Context = c
|
||||
log := e.GetLogger()
|
||||
tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1)
|
||||
// R2: see the matching call and comment in Preview above.
|
||||
applyInferredColumnWidths(tab.Columns)
|
||||
|
||||
basePath := "template/v4/"
|
||||
routerFile := basePath + "no_actions/router_check_role.go.template"
|
||||
@@ -191,10 +249,10 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
|
||||
e.Error(500, err, fmt.Sprintf("路由模版失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
t4, err := template.ParseFiles(basePath + "js.go.template")
|
||||
t4, err := template.ParseFiles(basePath + "ts.go.template")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("js模版解析失败!错误详情:%s", err.Error()))
|
||||
e.Error(500, err, fmt.Sprintf("ts模版解析失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
t5, err := template.ParseFiles(basePath + "vue.go.template")
|
||||
@@ -215,6 +273,19 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
|
||||
e.Error(500, err, fmt.Sprintf("service模版失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
// t8/t9 back F3/F9 (PRD 010): see the matching comment in Preview above.
|
||||
t8, err := parseGenTemplate(basePath + "lang-zh.go.template")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("zh语言包模版解析失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
t9, err := parseGenTemplate(basePath + "lang-en.go.template")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("en语言包模版解析失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
_ = pkg.PathCreate("./app/" + tab.PackageName + "/apis/")
|
||||
_ = pkg.PathCreate("./app/" + tab.PackageName + "/models/")
|
||||
@@ -227,6 +298,23 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
|
||||
e.Error(500, err, fmt.Sprintf("views目录创建失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
// gen/{PackageName}/ nests under each locale so go-admin-ui's
|
||||
// gen-namespace.ts (`./*/*.ts` glob, one level under gen/) picks the file
|
||||
// up - a flat gen/{BusinessName}.ts would let two tables in different
|
||||
// packages silently overwrite each other's translations, since
|
||||
// BusinessName only has a pattern check, no uniqueness check.
|
||||
err = pkg.PathCreate(config.GenConfig.FrontPath + "/lang/zh-CN/gen/" + tab.PackageName + "/")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("zh语言包目录创建失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
err = pkg.PathCreate(config.GenConfig.FrontPath + "/lang/en-US/gen/" + tab.PackageName + "/")
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
e.Error(500, err, fmt.Sprintf("en语言包目录创建失败!错误详情:%s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
var b1 bytes.Buffer
|
||||
err = t1.Execute(&b1, tab)
|
||||
@@ -242,13 +330,19 @@ func (e Gen) NOActionsGen(c *gin.Context, tab tools.SysTables) {
|
||||
err = t6.Execute(&b6, tab)
|
||||
var b7 bytes.Buffer
|
||||
err = t7.Execute(&b7, tab)
|
||||
var b8 bytes.Buffer
|
||||
err = t8.Execute(&b8, tab)
|
||||
var b9 bytes.Buffer
|
||||
err = t9.Execute(&b9, tab)
|
||||
pkg.FileCreate(b1, "./app/"+tab.PackageName+"/models/"+tab.TBName+".go")
|
||||
pkg.FileCreate(b2, "./app/"+tab.PackageName+"/apis/"+tab.TBName+".go")
|
||||
pkg.FileCreate(b3, "./app/"+tab.PackageName+"/router/"+tab.TBName+".go")
|
||||
pkg.FileCreate(b4, config.GenConfig.FrontPath+"/api/"+tab.PackageName+"/"+tab.MLTBName+".js")
|
||||
pkg.FileCreate(b4, config.GenConfig.FrontPath+"/api/"+tab.PackageName+"/"+tab.MLTBName+".ts")
|
||||
pkg.FileCreate(b5, config.GenConfig.FrontPath+"/views/"+tab.PackageName+"/"+tab.MLTBName+"/index.vue")
|
||||
pkg.FileCreate(b6, "./app/"+tab.PackageName+"/service/dto/"+tab.TBName+".go")
|
||||
pkg.FileCreate(b7, "./app/"+tab.PackageName+"/service/"+tab.TBName+".go")
|
||||
pkg.FileCreate(b8, config.GenConfig.FrontPath+"/lang/zh-CN/gen/"+tab.PackageName+"/"+tab.BusinessName+".ts")
|
||||
pkg.FileCreate(b9, config.GenConfig.FrontPath+"/lang/en-US/gen/"+tab.PackageName+"/"+tab.BusinessName+".ts")
|
||||
|
||||
}
|
||||
|
||||
@@ -302,7 +396,7 @@ func (e Gen) GenMenuAndApi(c *gin.Context) {
|
||||
}
|
||||
|
||||
table.TableId = id
|
||||
tab, _ := table.Get(e.Orm,true)
|
||||
tab, _ := table.Get(e.Orm, true)
|
||||
tab.MLTBName = strings.Replace(tab.TBName, "_", "-", -1)
|
||||
|
||||
Mmenu := dto.SysMenuInsertReq{}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/pkg"
|
||||
_ "github.com/go-admin-team/go-admin-core/v2/response"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
@@ -79,7 +80,7 @@ func (e SysTable) Get(c *gin.Context) {
|
||||
|
||||
var data tools.SysTables
|
||||
data.TableId, _ = pkg.StringToInt(c.Param("tableId"))
|
||||
result, err := data.Get(db,true)
|
||||
result, err := data.Get(db, true)
|
||||
if err != nil {
|
||||
log.Errorf("Get error, %s", err.Error())
|
||||
e.Error(500, err, "")
|
||||
@@ -106,7 +107,7 @@ func (e SysTable) GetSysTablesInfo(c *gin.Context) {
|
||||
if c.Request.FormValue("tableName") != "" {
|
||||
data.TBName = c.Request.FormValue("tableName")
|
||||
}
|
||||
result, err := data.Get(db,true)
|
||||
result, err := data.Get(db, true)
|
||||
if err != nil {
|
||||
log.Errorf("Get error, %s", err.Error())
|
||||
e.Error(500, err, "抱歉未找到相关信息")
|
||||
@@ -148,7 +149,8 @@ func (e SysTable) GetSysTablesTree(c *gin.Context) {
|
||||
// @Tags 工具 / 生成工具
|
||||
// @Accept application/json
|
||||
// @Product application/json
|
||||
// @Param tables query string false "tableName / 数据表名称"
|
||||
// @Param tables query string false "tableName / 数据表名称,逗号分隔"
|
||||
// @Param data body object false "tables / 同上,query 未带时从 JSON body 读"
|
||||
// @Success 200 {string} string "{"code": 200, "message": "添加成功"}"
|
||||
// @Success 200 {string} string "{"code": -1, "message": "添加失败"}"
|
||||
// @Router /api/v1/sys/tables/info [post]
|
||||
@@ -163,7 +165,13 @@ func (e SysTable) Insert(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
tablesList := strings.Split(c.Request.FormValue("tables"), ",")
|
||||
tablesList, err := tablesToImport(c)
|
||||
if err != nil {
|
||||
log.Errorf("read the table list, %s", err.Error())
|
||||
e.Error(500, err, "")
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < len(tablesList); i++ {
|
||||
|
||||
data, err := genTableInit(db, tablesList, i, c)
|
||||
@@ -184,6 +192,45 @@ func (e SysTable) Insert(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
// tablesToImport reads the comma-separated table list carried by an import
|
||||
// request, from the query string or from a JSON body.
|
||||
//
|
||||
// The list has only ever travelled in the query string, which is the single
|
||||
// place FormValue looks once the request declares itself as JSON. A front end
|
||||
// that puts it in the body instead therefore left this empty, and the import
|
||||
// went on to ask information_schema for a table named "" -- go-admin-ui v3.2.0
|
||||
// shipped exactly that, and every import failed with the message below.
|
||||
// Reading the body when the query has nothing keeps either front end working.
|
||||
func tablesToImport(c *gin.Context) ([]string, error) {
|
||||
raw := c.Request.FormValue("tables")
|
||||
if raw == "" {
|
||||
var body struct {
|
||||
Tables string `json:"tables"`
|
||||
}
|
||||
// A body that is absent, or shaped some other way, is not itself worth
|
||||
// reporting: the list is missing either way, and the message below says
|
||||
// so in the terms the caller asked in.
|
||||
if err := c.ShouldBindJSON(&body); err == nil {
|
||||
raw = body.Tables
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
names := make([]string, 0, len(parts))
|
||||
for _, name := range parts {
|
||||
// Splitting "" yields one empty name rather than nothing at all, so
|
||||
// without this an empty list reads as a request to import one table
|
||||
// whose name happens to be blank.
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, errors.New(emptyTableNameMsg)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func genTableInit(tx *gorm.DB, tablesList []string, i int, c *gin.Context) (tools.SysTables, error) {
|
||||
var data tools.SysTables
|
||||
var dbTable tools.DBTables
|
||||
@@ -321,6 +368,21 @@ func (e SysTable) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// PRD 010 F10: this bind-and-save path has no field-level validation of
|
||||
// its own (API契约.md §1.2/§2.1, D6) - see sys_tables_validate.go for
|
||||
// what each check guards and why colWidth is sanitized in place rather
|
||||
// than rejected.
|
||||
if err = validateAndSanitizeColumns(data.Columns); err != nil {
|
||||
log.Errorf("validate columns error, %s", err.Error())
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
if err = validateBusinessNameUnique(db, data.PackageName, data.BusinessName, data.TableId); err != nil {
|
||||
log.Errorf("validate businessName error, %s", err.Error())
|
||||
e.Error(500, err, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
data.UpdateBy = 0
|
||||
result, err := data.Update(db)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// newImportRequest builds the request an import arrives in. Where the table
|
||||
// list sits -- query or body -- is exactly what these tests are about, and it
|
||||
// is net/http's form parsing that decides what a handler can reach, so these go
|
||||
// through a real *http.Request rather than a hand-built one.
|
||||
func newImportRequest(target, contentType, body string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body))
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestTablesToImport(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
target string
|
||||
contentType string
|
||||
body string
|
||||
want []string
|
||||
wantErr bool
|
||||
}{{
|
||||
name: "from the query, as every front end before v3.2.0 sent it",
|
||||
target: "/sys/tables/info?tables=sys_user,sys_post",
|
||||
want: []string{"sys_user", "sys_post"},
|
||||
}, {
|
||||
name: "from a JSON body, as go-admin-ui v3.2.0 sends it",
|
||||
target: "/sys/tables/info",
|
||||
contentType: "application/json",
|
||||
body: `{"tables":"sys_user,sys_post"}`,
|
||||
want: []string{"sys_user", "sys_post"},
|
||||
}, {
|
||||
name: "the query wins when a request carries both",
|
||||
target: "/sys/tables/info?tables=sys_user",
|
||||
contentType: "application/json",
|
||||
body: `{"tables":"sys_post"}`,
|
||||
want: []string{"sys_user"},
|
||||
}, {
|
||||
name: "blank entries are dropped rather than imported as a nameless table",
|
||||
target: "/sys/tables/info?tables=sys_user,,%20,sys_post",
|
||||
want: []string{"sys_user", "sys_post"},
|
||||
}, {
|
||||
name: "a body carrying an empty list is an error",
|
||||
target: "/sys/tables/info",
|
||||
contentType: "application/json",
|
||||
body: `{"tables":""}`,
|
||||
wantErr: true,
|
||||
}, {
|
||||
name: "a body that is not JSON at all is an error, not a panic",
|
||||
target: "/sys/tables/info",
|
||||
contentType: "application/json",
|
||||
body: "sys_user",
|
||||
wantErr: true,
|
||||
}}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = newImportRequest(tc.target, tc.contentType, tc.body)
|
||||
|
||||
got, err := tablesToImport(c)
|
||||
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected an error, got %q", got)
|
||||
}
|
||||
if err.Error() != emptyTableNameMsg {
|
||||
t.Fatalf("message should be the one the front end shows, got %q", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||
t.Fatalf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// insertMsg runs one import through the wired handler. It asserts nothing about
|
||||
// the import succeeding -- it cannot, over sqlite -- only about how far the
|
||||
// request got, which the empty-list message is what distinguishes.
|
||||
func insertMsg(t *testing.T, target, contentType, body string) bodyOf {
|
||||
t.Helper()
|
||||
return serveJSON(t,
|
||||
newEngine(t, http.MethodPost, "/sys/tables/info", SysTable{}.Insert),
|
||||
newImportRequest(target, contentType, body))
|
||||
}
|
||||
|
||||
func TestInsert_ReadsTheTableListFromEitherPlace(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
target string
|
||||
contentType string
|
||||
body string
|
||||
}{
|
||||
{"query", "/sys/tables/info?tables=sys_user", "", ""},
|
||||
{"JSON body", "/sys/tables/info", "application/json", `{"tables":"sys_user"}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := insertMsg(t, tc.target, tc.contentType, tc.body); got.Msg == emptyTableNameMsg {
|
||||
t.Fatalf("request carried a table name and was still rejected as empty: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsert_RejectsAMissingTableList(t *testing.T) {
|
||||
if got := insertMsg(t, "/sys/tables/info", "", ""); got.Msg != emptyTableNameMsg {
|
||||
t.Fatalf("missing table list should be rejected, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
)
|
||||
|
||||
// jsonFieldPattern accepts any legal JS/TS identifier that starts with a
|
||||
// lowercase letter - not businessName's rule.
|
||||
//
|
||||
// This used to be businessName's own pattern (^[a-z][A-Za-z]+$, requiring at
|
||||
// least two letters and no digits), copied over on the theory that jsonField
|
||||
// "should tighten to the same identifier shape". That theory does not hold:
|
||||
// businessName is typed by a person on genInfoForm.vue, so a strict pattern
|
||||
// is a reasonable guardrail on human input. jsonField is computed by the
|
||||
// importer from the column name (sys_tables.go's namelist/JsonField loop) -
|
||||
// nobody types it, so the same pattern only rejects names the importer
|
||||
// legitimately produces. A one-letter column ("x") or a column ending in a
|
||||
// digit ("address2", "a1") both import to a single camelCase word with no
|
||||
// separators to re-capitalize, and both used to fail this check - meaning a
|
||||
// table that merely contained such a column could never save any config
|
||||
// again, unrelated columns included, since this check runs over every
|
||||
// column on every Update.
|
||||
//
|
||||
// What still has to be rejected is a jsonField that cannot be a raw object
|
||||
// key at all: empty, containing whitespace/punctuation, or leading with a
|
||||
// digit (`2faEnabled: 1` is not valid JS - identifiers cannot start with a
|
||||
// digit, and this is what lands as the property name in gen.go's generated
|
||||
// interface / lang file, both unquoted). Hence still anchoring on a
|
||||
// lowercase letter first, but no longer requiring a second character or
|
||||
// forbidding digits after it.
|
||||
var jsonFieldPattern = regexp.MustCompile(`^[a-z][A-Za-z0-9]*$`)
|
||||
|
||||
// colWidthMin/colWidthMax are API契约.md §2.1's suggested range for colWidth.
|
||||
const (
|
||||
colWidthMin = 40
|
||||
colWidthMax = 800
|
||||
)
|
||||
|
||||
// expressionMarkers flags the "meant to be evaluated" shapes API契约.md §2.1
|
||||
// says defaultValue must not carry: it is spliced into the generated
|
||||
// defaultModel() as a literal and never evaluated, so anything that looks
|
||||
// like a function call or a block is rejected outright rather than
|
||||
// generating code that silently does nothing.
|
||||
var expressionMarkers = []string{"(", ")", "{", "}", "`", ";", "=>"}
|
||||
|
||||
// validateAndSanitizeColumns enforces PRD 010 F10 on the columns carried by
|
||||
// a table update (sys_tables.go:357's Update handler, the one bind-and-save
|
||||
// path with no field-level validation at all - see API契约.md §1.2/§2.1,
|
||||
// decision D6).
|
||||
//
|
||||
// jsonField and defaultValue problems reject the request outright: letting
|
||||
// either through would corrupt the generated i18n file silently (a
|
||||
// duplicate or malformed jsonField becomes a duplicate or invalid key in
|
||||
// gen/{PackageName}/{BusinessName}.ts, see the lang-zh/lang-en templates).
|
||||
// An out-of-range colWidth does not reject - §2.1 says it "falls back to
|
||||
// the inferred value", so this resets it to the 0 sentinel in place and lets
|
||||
// R2's inference take over, the same as if the field had never been set.
|
||||
func validateAndSanitizeColumns(columns []tools.SysColumns) error {
|
||||
seen := make(map[string]bool, len(columns))
|
||||
for i := range columns {
|
||||
col := &columns[i]
|
||||
|
||||
if !jsonFieldPattern.MatchString(col.JsonField) {
|
||||
return fmt.Errorf("jsonField 格式不合法:%q,须以小写字母开头且只能包含英文字母", col.JsonField)
|
||||
}
|
||||
if seen[col.JsonField] {
|
||||
return fmt.Errorf("jsonField 在同一张表内重复:%q", col.JsonField)
|
||||
}
|
||||
seen[col.JsonField] = true
|
||||
|
||||
if col.ColWidth != 0 && (col.ColWidth < colWidthMin || col.ColWidth > colWidthMax) {
|
||||
col.ColWidth = 0
|
||||
}
|
||||
|
||||
for _, marker := range expressionMarkers {
|
||||
if strings.Contains(col.DefaultValue, marker) {
|
||||
return fmt.Errorf("defaultValue 不允许包含表达式或函数调用内容:%q", col.DefaultValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateBusinessNameUnique enforces PRD 010 F10's other half: two tables
|
||||
// sharing (packageName, businessName) write the same generated language
|
||||
// pack path, gen/{PackageName}/{BusinessName}.ts (see gen.go's
|
||||
// NOActionsGen), so the second one silently overwrites the first's
|
||||
// translations. tableID excludes the row being saved, so a table updating
|
||||
// its own unchanged name does not trip the check on itself.
|
||||
//
|
||||
// G10's other concern - colliding with the built-in admin/* i18n namespace -
|
||||
// does not apply here anymore: D9 moved generated keys to their own gen/
|
||||
// namespace, so this only has to guard generated tables against each other.
|
||||
func validateBusinessNameUnique(db *gorm.DB, packageName, businessName string, tableID int) error {
|
||||
var count int64
|
||||
err := db.Table("sys_tables").
|
||||
Where("package_name = ? AND business_name = ? AND table_id != ?", packageName, businessName, tableID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("packageName=%q 下 businessName=%q 已被其它表使用", packageName, businessName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
)
|
||||
|
||||
func TestValidateAndSanitizeColumns_JsonFieldFormat(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
jsonField string
|
||||
wantErr bool
|
||||
}{
|
||||
{"lower camelCase", "userName", false},
|
||||
{"two-letter lowercase", "id", false},
|
||||
// The importer's own output (sys_tables.go's namelist/JsonField
|
||||
// loop), not made up: a single-letter column ("x"), and a column
|
||||
// whose last name segment ends in a digit ("address2", "a1") both
|
||||
// produce a jsonField with no separator left to re-capitalize.
|
||||
// These three used to be rejected - the whole point of this fix.
|
||||
{"single letter, real importer output for a column named x", "x", false},
|
||||
{"letters then a trailing digit, real importer output for address2", "address2", false},
|
||||
{"two letters then a digit, real importer output for a1", "a1", false},
|
||||
{"leading underscore rejected", "_id", true},
|
||||
{"leading digit rejected (not a legal identifier start)", "1name", true},
|
||||
{"snake_case rejected (importer never emits an underscore)", "user_name", true},
|
||||
{"dot rejected, would break the gen/{pkg}/{biz}.ts key path", "user.name", true},
|
||||
{"empty rejected", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: tc.jsonField}})
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("jsonField %q: want error, got nil", tc.jsonField)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("jsonField %q: want no error, got %v", tc.jsonField, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndSanitizeColumns_JsonFieldUniqueWithinTable(t *testing.T) {
|
||||
err := validateAndSanitizeColumns([]tools.SysColumns{
|
||||
{JsonField: "name"},
|
||||
{JsonField: "name"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("want error for a jsonField repeated in the same table, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndSanitizeColumns_ColWidthOutOfRangeIsSanitizedNotRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
width int
|
||||
want int
|
||||
}{
|
||||
{"zero (unconfigured) is left alone", 0, 0},
|
||||
{"in range is left alone", 150, 150},
|
||||
{"lower bound is left alone", colWidthMin, colWidthMin},
|
||||
{"upper bound is left alone", colWidthMax, colWidthMax},
|
||||
{"too small falls back to the sentinel", colWidthMin - 1, 0},
|
||||
{"too large falls back to the sentinel", colWidthMax + 1, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cols := []tools.SysColumns{{JsonField: "name", ColWidth: tc.width}}
|
||||
if err := validateAndSanitizeColumns(cols); err != nil {
|
||||
t.Fatalf("colWidth %d: want no error (out-of-range sanitizes, it does not reject), got %v", tc.width, err)
|
||||
}
|
||||
if cols[0].ColWidth != tc.want {
|
||||
t.Errorf("colWidth %d: want sanitized to %d, got %d", tc.width, tc.want, cols[0].ColWidth)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAndSanitizeColumns_DefaultValueExpressionRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
defaultValue string
|
||||
wantErr bool
|
||||
}{
|
||||
{"plain literal", "0", false},
|
||||
{"plain string literal", "active", false},
|
||||
{"empty (unconfigured)", "", false},
|
||||
{"function call rejected", "Date.now()", true},
|
||||
{"template literal rejected", "`x`", true},
|
||||
{"arrow function rejected", "() => 1", true},
|
||||
{"statement separator rejected", "1; drop", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateAndSanitizeColumns([]tools.SysColumns{{JsonField: "name", DefaultValue: tc.defaultValue}})
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("defaultValue %q: want error, got nil", tc.defaultValue)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("defaultValue %q: want no error, got %v", tc.defaultValue, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newBusinessNameTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(new(tools.SysTables)); err != nil {
|
||||
t.Fatalf("migrate sys_tables: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestValidateBusinessNameUnique(t *testing.T) {
|
||||
db := newBusinessNameTestDB(t)
|
||||
|
||||
existing := tools.SysTables{TBName: "sys_widget", PackageName: "biz", BusinessName: "widget"}
|
||||
if err := db.Table("sys_tables").Create(&existing).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
t.Run("same package, same businessName, different table: rejected", func(t *testing.T) {
|
||||
other := tools.SysTables{TBName: "sys_widget_copy", PackageName: "biz", BusinessName: "widget"}
|
||||
if err := db.Table("sys_tables").Create(&other).Error; err != nil {
|
||||
t.Fatalf("seed second row: %v", err)
|
||||
}
|
||||
// Unscoped: a plain Delete only soft-deletes (SysTables carries
|
||||
// common.ModelTime), which would leave this row's businessName
|
||||
// looking taken for the next subtest - production's own delete path
|
||||
// (SysTables.BatchDelete) hard-deletes for the same reason.
|
||||
defer db.Table("sys_tables").Unscoped().Delete(&other)
|
||||
|
||||
if err := validateBusinessNameUnique(db, "biz", "widget", other.TableId); err == nil {
|
||||
t.Error("want error for a businessName already used by another table in the same package, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different package, same businessName: allowed", func(t *testing.T) {
|
||||
if err := validateBusinessNameUnique(db, "other-pkg", "widget", 0); err != nil {
|
||||
t.Errorf("want no error across different packages, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a table checking against its own current name: allowed", func(t *testing.T) {
|
||||
if err := validateBusinessNameUnique(db, "biz", "widget", existing.TableId); err != nil {
|
||||
t.Errorf("want no error when the only match is the row being saved itself, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -45,6 +45,19 @@ type SysColumns struct {
|
||||
CreateBy int `gorm:"column:create_by;size:20;" json:"createBy"`
|
||||
UpdateBy int `gorm:"column:update_By;size:20;" json:"updateBy"`
|
||||
|
||||
// ColWidth and DefaultValue back PRD 010 F1/F2 (代码生成器前端模板迁移 Vue 3).
|
||||
// Both use a sentinel default (0 / "") rather than NULL - see
|
||||
// docs-prd/010-代码生成器前端模板迁移Vue3/数据库变更.md §1.1: a non-pointer
|
||||
// int/string field can never read NULL back out, and NULL would give
|
||||
// "unconfigured" two representations instead of one. Callers test
|
||||
// ColWidth == 0 / DefaultValue == "" to detect "not configured".
|
||||
//
|
||||
// ColWidth deliberately has no gorm size tag: this codebase's "size:N"
|
||||
// convention on numeric fields maps to a narrow SQL integer type (see
|
||||
// column_width_test.go), and col_width needs to hold values up to 800.
|
||||
ColWidth int `gorm:"column:col_width;not null;default:0;comment:table column width in px, 0 = not configured" json:"colWidth"`
|
||||
DefaultValue string `gorm:"column:default_value;size:255;not null;default:'';comment:form field default value, empty = not configured" json:"defaultValue"`
|
||||
|
||||
common.ModelTime
|
||||
}
|
||||
|
||||
@@ -97,5 +110,23 @@ func (e *SysColumns) Update(tx *gorm.DB) (update SysColumns, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// Updates(&e) above skips zero-value fields (GORM's struct-form Updates
|
||||
// always does), but ColWidth/DefaultValue's own "unconfigured" sentinel
|
||||
// is 0/"" (see the field comments on SysColumns) - so clearing either one
|
||||
// back to its sentinel is indistinguishable, to a struct-form Updates,
|
||||
// from "the caller didn't touch this field" and silently does not get
|
||||
// written. A map-form Updates does not skip zero values, so it is used
|
||||
// here for just these two columns rather than widening this to
|
||||
// Select("*") (which would also start writing every other zero-valued
|
||||
// field on this struct - Sort, the Pk/Required/... bools - and that is a
|
||||
// pre-existing gap in this method affecting fields outside PRD 010's
|
||||
// scope, not fixed here).
|
||||
if err = tx.Table("sys_columns").Model(&update).Updates(map[string]interface{}{
|
||||
"col_width": e.ColWidth,
|
||||
"default_value": e.DefaultValue,
|
||||
}).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GORM's Updates(struct) skips zero-value fields, and PRD 010 F1/F2 chose 0 /
|
||||
// "" as the sentinel for "unconfigured" (docs-prd/010-代码生成器前端模板迁移Vue3/
|
||||
// 数据库变更.md §1.1). Put those together and Update can set ColWidth/
|
||||
// DefaultValue but never clear them back to the sentinel: the struct-form
|
||||
// Updates call silently drops the very values this feature needs to write.
|
||||
func TestSysColumnsUpdateClearsSentinelFields(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(new(SysColumns)); err != nil {
|
||||
t.Fatalf("migrate sys_columns: %v", err)
|
||||
}
|
||||
|
||||
col := SysColumns{TableId: 1, ColumnName: "status", ColWidth: 150, DefaultValue: "active"}
|
||||
if _, err := col.Create(db); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// Reset back to the sentinel - the UI action for "go back to inferred
|
||||
// width / no default", not merely "never configured".
|
||||
update := SysColumns{ColumnId: col.ColumnId, ColWidth: 0, DefaultValue: ""}
|
||||
if _, err := update.Update(db); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
|
||||
var got SysColumns
|
||||
if err := db.Table("sys_columns").First(&got, col.ColumnId).Error; err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if got.ColWidth != 0 {
|
||||
t.Errorf("colWidth: want 0 (cleared), got %d - Update() did not write the sentinel back", got.ColWidth)
|
||||
}
|
||||
if got.DefaultValue != "" {
|
||||
t.Errorf("defaultValue: want \"\" (cleared), got %q - Update() did not write the sentinel back", got.DefaultValue)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -5,4 +5,4 @@ import "go-admin/app/demo/router"
|
||||
func init() {
|
||||
//注册路由 fixme 其他应用的路由,在本目录新建文件放在init方法
|
||||
AppRouters = append(AppRouters, router.InitRouter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
"go-admin/common/health"
|
||||
commonmodels "go-admin/common/models"
|
||||
)
|
||||
|
||||
// schemaCheckName is what a failing schema reports itself as in /ready's body.
|
||||
const schemaCheckName = "schema"
|
||||
|
||||
// registerSchemaCheck adds the pending-migration check to readiness.
|
||||
//
|
||||
// Readiness rather than a refusal to start, and rather than a log line alone.
|
||||
// The two probes answer different questions: liveness is "restart me", and a
|
||||
// process whose database is on the wrong schema comes back to the same schema,
|
||||
// so restarting is not the answer. Readiness is "send me requests", and with a
|
||||
// schema the binary does not match the answer is no.
|
||||
//
|
||||
// Issue #919 is what the absence of this looked like: the process started,
|
||||
// both probes passed, and the first sign of trouble was a login failing with a
|
||||
// driver-level encoding error. Refusing to start would have been the wrong fix
|
||||
// - a process that exits tells an operator less than one that runs and says
|
||||
// why, and under an orchestrator it crash-loops - while a log line alone is
|
||||
// not something an orchestrator can act on.
|
||||
func registerSchemaCheck() {
|
||||
health.Register(schemaCheckName, schemaCheck)
|
||||
}
|
||||
|
||||
// schemaCheck fails while any tenant database is behind the migrations this
|
||||
// binary registers.
|
||||
//
|
||||
// Any one of them, rather than only the tenant being served: migrations are
|
||||
// applied to every database in one run, so one database behind means that run
|
||||
// did not finish. Serving the rest would let a half-applied deploy look like a
|
||||
// partial success.
|
||||
//
|
||||
// Evaluated per request rather than decided at start-up, so that running
|
||||
// migrate clears it without a restart.
|
||||
func schemaCheck(ctx context.Context) error {
|
||||
registered := migration.RegisteredVersions()
|
||||
if len(registered) == 0 {
|
||||
// Nothing registered means nothing can be pending, which is the honest
|
||||
// answer for a tree with no migrations. It is also what a broken build
|
||||
// would produce - the registry is filled by init() in packages the
|
||||
// binary has to link - so cmd/api's dependency test asserts the real
|
||||
// binary links them.
|
||||
return nil
|
||||
}
|
||||
|
||||
behind := make([]string, 0, 2)
|
||||
for name, db := range sdk.Runtime.GetAllDb() {
|
||||
applied, err := appliedVersions(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading applied migrations for %q: %w", name, err)
|
||||
}
|
||||
if pending := pendingVersions(registered, applied); len(pending) > 0 {
|
||||
behind = append(behind, fmt.Sprintf("%s is %d behind, first pending %s",
|
||||
name, len(pending), pending[0]))
|
||||
}
|
||||
}
|
||||
if len(behind) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(behind)
|
||||
return fmt.Errorf("%s; run `go-admin migrate -c <config>` and see `go-admin migrate status`",
|
||||
strings.Join(behind, "; "))
|
||||
}
|
||||
|
||||
// appliedVersions reads what sys_migration records for one database.
|
||||
//
|
||||
// A missing table is not an error: a database that has never been migrated has
|
||||
// applied nothing, which is exactly what the caller needs to hear, and is the
|
||||
// state a first deploy is in.
|
||||
func appliedVersions(ctx context.Context, db *gorm.DB) (map[string]bool, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("no database")
|
||||
}
|
||||
db = db.WithContext(ctx)
|
||||
if !db.Migrator().HasTable(&commonmodels.Migration{}) {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
var rows []commonmodels.Migration
|
||||
if err := db.Select("version").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]bool, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.Version] = true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pendingVersions returns the registered versions applied does not contain.
|
||||
//
|
||||
// Split out and taking both sides as arguments because the registry is
|
||||
// process-wide and filled by init() in packages cmd/api does not import: a
|
||||
// test in this package cannot arrange it, so the arranging part is the part
|
||||
// that is not tested here.
|
||||
func pendingVersions(registered []string, applied map[string]bool) []string {
|
||||
out := make([]string, 0)
|
||||
for _, v := range registered {
|
||||
if !applied[v] {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
commonmodels "go-admin/common/models"
|
||||
)
|
||||
|
||||
func TestPendingVersionsReportsOnlyWhatIsNotApplied(t *testing.T) {
|
||||
registered := []string{"1000_a", "2000_b", "3000_c"}
|
||||
applied := map[string]bool{"1000_a": true, "3000_c": true}
|
||||
|
||||
got := pendingVersions(registered, applied)
|
||||
if len(got) != 1 || got[0] != "2000_b" {
|
||||
t.Errorf("pending = %v, want [2000_b]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingVersionsIsEmptyWhenTheDatabaseIsCurrent(t *testing.T) {
|
||||
registered := []string{"1000_a", "2000_b"}
|
||||
applied := map[string]bool{"1000_a": true, "2000_b": true}
|
||||
|
||||
if got := pendingVersions(registered, applied); len(got) != 0 {
|
||||
t.Errorf("pending = %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A row recorded that this binary no longer registers is not pending. It is
|
||||
// the orphan `migrate status` already reports, and readiness has nothing to
|
||||
// say about it: the schema is ahead, not behind, and requests will be served
|
||||
// correctly.
|
||||
func TestPendingVersionsIgnoresAppliedRowsNothingRegisters(t *testing.T) {
|
||||
registered := []string{"1000_a"}
|
||||
applied := map[string]bool{"1000_a": true, "9999_gone": true}
|
||||
|
||||
if got := pendingVersions(registered, applied); len(got) != 0 {
|
||||
t.Errorf("pending = %v, want none - an orphaned row is not a pending migration", got)
|
||||
}
|
||||
}
|
||||
|
||||
func memoryDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Migrator().DropTable(&commonmodels.Migration{}) })
|
||||
return db
|
||||
}
|
||||
|
||||
// A first deploy has no sys_migration table. That is "nothing applied", not an
|
||||
// error: reporting it as one would make the check fail for a reason the
|
||||
// operator cannot act on, on the one deployment where every migration really
|
||||
// is pending.
|
||||
func TestAppliedVersionsTreatsAMissingTableAsNothingApplied(t *testing.T) {
|
||||
db := memoryDB(t)
|
||||
db.Migrator().DropTable(&commonmodels.Migration{})
|
||||
|
||||
got, err := appliedVersions(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatalf("appliedVersions: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("applied = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppliedVersionsReadsWhatTheTableHolds(t *testing.T) {
|
||||
db := memoryDB(t)
|
||||
if err := db.AutoMigrate(&commonmodels.Migration{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
db.Create(&commonmodels.Migration{Version: "1000_a"})
|
||||
db.Create(&commonmodels.Migration{Version: "2000_b"})
|
||||
|
||||
got, err := appliedVersions(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatalf("appliedVersions: %v", err)
|
||||
}
|
||||
if !got["1000_a"] || !got["2000_b"] || len(got) != 2 {
|
||||
t.Errorf("applied = %v, want the two rows written", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The check is only worth anything if the registry it reads is populated in
|
||||
// the binary that serves requests, and it is filled by init() in packages
|
||||
// cmd/api does not import - cmd/migrate blank-imports them, and cmd wires both
|
||||
// subcommands into one binary.
|
||||
//
|
||||
// This cannot be asserted from an ordinary test: importing the version package
|
||||
// to look at the registry would put it in the test binary's dependency graph
|
||||
// and pass whatever the real binary links. So ask the build instead.
|
||||
//
|
||||
// Without this, dropping those blank imports leaves a check that reports
|
||||
// "nothing pending" for every database forever, and every test above still
|
||||
// passes.
|
||||
func TestTheServingBinaryLinksTheMigrationRegistry(t *testing.T) {
|
||||
out, err := exec.Command("go", "list", "-deps", "go-admin").Output()
|
||||
if err != nil {
|
||||
t.Skipf("go list unavailable: %v", err)
|
||||
}
|
||||
deps := string(out)
|
||||
|
||||
const versions = "go-admin/cmd/migrate/migration/version"
|
||||
if !strings.Contains(deps, versions+"\n") {
|
||||
t.Errorf("the main package does not link %s, so the schema check would "+
|
||||
"read an empty registry and report every database as current", versions)
|
||||
}
|
||||
|
||||
// Negative control: a package the binary genuinely must not link, so that a
|
||||
// `deps` that somehow contained everything would fail here rather than pass
|
||||
// the assertion above for the wrong reason.
|
||||
const notLinked = "go-admin/tools/checksilent"
|
||||
if strings.Contains(deps, notLinked+"\n") {
|
||||
t.Errorf("%s is in the binary's dependency closure, so this test cannot "+
|
||||
"tell a real link from a query that matches anything", notLinked)
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,11 @@ func setup() {
|
||||
// can call the API, which is only true once the socket is accepting.
|
||||
sdk.Runtime.SetPhase(runtime.AfterListen, startCronJobs)
|
||||
|
||||
// Registered before the configuration is read, because it registers a
|
||||
// callback rather than reading anything: the check runs per request and
|
||||
// asks the databases that exist then.
|
||||
registerSchemaCheck()
|
||||
|
||||
//1. 读取配置
|
||||
bootstrap.SetupConfig(
|
||||
file.NewSource(file.WithPath(configYml)),
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ func init() {
|
||||
rootCmd.AddCommand(app.StartCmd)
|
||||
}
|
||||
|
||||
//Execute : apply commands
|
||||
// Execute : apply commands
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(-1)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A deployment decides whether to start the new version on this command's
|
||||
// exit code. Before this batch the only failure that produced one was a
|
||||
// failing migration function, and it produced it by ending the process from
|
||||
// inside the migration engine; moving that out would have taken the last
|
||||
// reported failure with it.
|
||||
func TestExitOnErrorEndsTheCommandNonZero(t *testing.T) {
|
||||
var codes []int
|
||||
osExit = func(c int) { codes = append(codes, c) }
|
||||
t.Cleanup(func() { osExit = origExit })
|
||||
|
||||
var out bytes.Buffer
|
||||
exitOnError(&out, errors.New("the tenant database is unreachable"))
|
||||
|
||||
if len(codes) != 1 || codes[0] != 1 {
|
||||
t.Errorf("exit codes = %v, want [1]", codes)
|
||||
}
|
||||
if !strings.Contains(out.String(), "the tenant database is unreachable") {
|
||||
t.Errorf("the reason was not reported: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitOnErrorLetsSuccessThrough(t *testing.T) {
|
||||
var codes []int
|
||||
osExit = func(c int) { codes = append(codes, c) }
|
||||
t.Cleanup(func() { osExit = origExit })
|
||||
|
||||
var out bytes.Buffer
|
||||
exitOnError(&out, nil)
|
||||
|
||||
if len(codes) != 0 {
|
||||
t.Errorf("a successful migration exited with %v", codes)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Errorf("a successful migration wrote %q", out.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/app"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
)
|
||||
|
||||
// engine is the part of the migration engine the installer drives.
|
||||
//
|
||||
// An interface rather than *migration.Migration because the concrete type is
|
||||
// a package-level singleton with no exported constructor, so a test that took
|
||||
// it would be sharing one registry with every other test in the process.
|
||||
type engine interface {
|
||||
SetDb(*gorm.DB)
|
||||
Status() ([]migration.StatusEntry, error)
|
||||
MigrateApp(string) error
|
||||
}
|
||||
|
||||
// installReport is what an install did, for the command to print.
|
||||
type installReport struct {
|
||||
Code string
|
||||
// Version is the manifest version this run recorded.
|
||||
Version string
|
||||
// Previous is the version sys_app held before this run, empty when this
|
||||
// is the first install.
|
||||
Previous string
|
||||
// Applied lists the versions this run brought in, in the order they were
|
||||
// applied. Empty on a no-op, and also empty on a run that only corrected
|
||||
// sys_app - the difference is NoOp.
|
||||
Applied []string
|
||||
// NoOp says nothing was left to do: the app is recorded as installed, at
|
||||
// this same version, with no migration outstanding.
|
||||
NoOp bool
|
||||
}
|
||||
|
||||
// install brings one application up to the version its manifest declares.
|
||||
//
|
||||
// Three phases, each committing on its own. They are not one transaction and
|
||||
// cannot be: an application's versions are separate migration files, and a
|
||||
// DDL statement inside any of them commits the transaction around it on
|
||||
// MySQL, which destroys an outer transaction and every savepoint taken from
|
||||
// it. So this does not
|
||||
// promise that a half-installed application cannot happen. It promises that
|
||||
// one is visible when it does: phase A writes "installing" before anything
|
||||
// that can fail, and phase C turns that into "installed" or "failed".
|
||||
//
|
||||
// What is left to apply comes from sys_migration, never from sys_app.
|
||||
// sys_app is a derived view - a summary for a human, and the answer to "which
|
||||
// version does this app think it is at". If it were the authority, then an
|
||||
// operator who deleted sys_migration rows by hand would be told an app is
|
||||
// installed while its schema is not, which is worse than not knowing.
|
||||
func install(db *gorm.DB, eng engine, m app.Manifest) (installReport, error) {
|
||||
code := migration.NormalizeAppCode(m.Code)
|
||||
rep := installReport{Code: code, Version: m.Version}
|
||||
if code == "" {
|
||||
return rep, errors.New("the manifest declares no app code")
|
||||
}
|
||||
if code == migration.FrameworkAppCode {
|
||||
// Installing the framework is what `migrate` is, and the framework
|
||||
// has no manifest and no sys_app row. Saying so beats writing a row
|
||||
// that nothing else in this batch expects to exist.
|
||||
return rep, fmt.Errorf("%q is the framework's own migrations, not an application; run `migrate` for those", code)
|
||||
}
|
||||
if !db.Migrator().HasTable(&adminmodels.SysApp{}) {
|
||||
return rep, errors.New("sys_app does not exist; run `migrate` first to bring the framework's own tables up to date")
|
||||
}
|
||||
|
||||
eng.SetDb(db)
|
||||
|
||||
row, found, err := loadApp(db, code)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
// sameVersion is only meaningful when found; it stays false otherwise.
|
||||
// The comparison happens here, before phase A, so an unparseable
|
||||
// recorded version is refused while it is still readable rather than
|
||||
// after being overwritten.
|
||||
sameVersion := false
|
||||
if found {
|
||||
rep.Previous = row.Version
|
||||
cmp, err := app.Compare(m.Version, row.Version)
|
||||
if err != nil {
|
||||
return rep, fmt.Errorf("comparing %s against the recorded %s: %w", m.Version, row.Version, err)
|
||||
}
|
||||
if cmp < 0 {
|
||||
return rep, fmt.Errorf("%s is recorded at %s; installing %s would be a downgrade, which is not supported",
|
||||
code, row.Version, m.Version)
|
||||
}
|
||||
sameVersion = cmp == 0
|
||||
}
|
||||
|
||||
if err := requiresInstalled(db, code, m); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
|
||||
pending, err := pendingFor(eng, code)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
|
||||
// Nothing outstanding, recorded as installed, at this same version. All
|
||||
// three, and the first one comes from sys_migration: a row that says
|
||||
// installed while a migration of its has never run is exactly the case
|
||||
// sys_app must not be believed about. AppInstalling is not installed -
|
||||
// it is what a row reads as after the process was killed partway.
|
||||
if found && sameVersion && row.Status == adminmodels.AppInstalled && len(pending) == 0 {
|
||||
rep.NoOp = true
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// Phase A: the attempt is on disk before anything that can fail.
|
||||
now := time.Now()
|
||||
if err := beginInstall(db, &row, m, code, found, now); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
|
||||
// Phase B: no atomicity across these, by the nature of the thing.
|
||||
runErr := eng.MigrateApp(code)
|
||||
|
||||
// Phase C.
|
||||
if runErr != nil {
|
||||
failed := ""
|
||||
var vf *migration.VersionFailure
|
||||
if errors.As(runErr, &vf) {
|
||||
failed = vf.Version
|
||||
}
|
||||
if err := markFailed(db, code, failed, runErr, time.Now()); err != nil {
|
||||
return rep, errors.Join(runErr, fmt.Errorf("recording the failure on sys_app: %w", err))
|
||||
}
|
||||
return rep, runErr
|
||||
}
|
||||
if err := markInstalled(db, code, row.InstalledAt, time.Now()); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
rep.Applied = pending
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// loadApp reads the sys_app row for code. A missing row is not an error: it
|
||||
// is what a first install looks like.
|
||||
func loadApp(db *gorm.DB, code string) (adminmodels.SysApp, bool, error) {
|
||||
var row adminmodels.SysApp
|
||||
err := db.Where("app_code = ?", code).First(&row).Error
|
||||
if err == nil {
|
||||
return row, true, nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return adminmodels.SysApp{}, false, nil
|
||||
}
|
||||
return adminmodels.SysApp{}, false, fmt.Errorf("reading sys_app for %q: %w", code, err)
|
||||
}
|
||||
|
||||
// requiresInstalled refuses an install whose declared dependencies are not
|
||||
// installed, and names the ones that are not.
|
||||
//
|
||||
// It does not install them. "Install this application" would otherwise mean
|
||||
// "and everything it happens to name, and everything those name" - a blast
|
||||
// radius the operator did not ask for and cannot see before it happens. What
|
||||
// they get instead is a list and the order to do it in.
|
||||
//
|
||||
// An unfinished or failed dependency counts as missing, and says which it is:
|
||||
// "not installed" sends someone to install it, "did not finish" sends them to
|
||||
// look at why.
|
||||
func requiresInstalled(db *gorm.DB, code string, m app.Manifest) error {
|
||||
if len(m.Requires) == 0 {
|
||||
return nil
|
||||
}
|
||||
apps, err := loadApps(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var why, what []string
|
||||
for _, req := range m.Requires {
|
||||
want := migration.NormalizeAppCode(req)
|
||||
if want == "" {
|
||||
continue
|
||||
}
|
||||
var reason string
|
||||
switch row, ok := apps[want]; {
|
||||
case !ok:
|
||||
reason = "not installed"
|
||||
case row.Status == adminmodels.AppFailed:
|
||||
reason = "its install failed"
|
||||
case row.Status == adminmodels.AppInstalling:
|
||||
reason = "its install did not finish"
|
||||
case row.Status != adminmodels.AppInstalled:
|
||||
// A status this binary has no name for. Saying so beats the
|
||||
// catch-all this used to be, which read any future value as
|
||||
// "did not finish" - a sentence that would be wrong for
|
||||
// whatever reason the value was added.
|
||||
reason = fmt.Sprintf("its status is %d, which this binary does not recognise", row.Status)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
why = append(why, want+" ("+reason+")")
|
||||
what = append(what, want)
|
||||
}
|
||||
if len(why) > 0 {
|
||||
return fmt.Errorf("%s requires %s; install %s first",
|
||||
code, strings.Join(why, ", "), strings.Join(what, " and "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refuseOnDependencyCycle reports a cycle anywhere in the registered
|
||||
// manifests, whether or not the application being installed is part of it.
|
||||
//
|
||||
// Over the whole set rather than one application's closure, because a cycle
|
||||
// between two applications neither of which is the one being installed is
|
||||
// still an authoring mistake, and finding it the day somebody happens to
|
||||
// install into it - with an error naming two applications they did not ask
|
||||
// for - is the worse time to find it.
|
||||
//
|
||||
// Requires naming an application that is not registered is not a cycle and
|
||||
// not reported here; that is requiresInstalled's answer to give, against the
|
||||
// database, at the time it matters.
|
||||
func refuseOnDependencyCycle(manifests map[string]app.Manifest) error {
|
||||
const (
|
||||
white = 0 // not visited
|
||||
grey = 1 // on the current path
|
||||
black = 2 // finished
|
||||
)
|
||||
colour := make(map[string]int, len(manifests))
|
||||
|
||||
codes := make([]string, 0, len(manifests))
|
||||
for code := range manifests {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
// Sorted, so the same set of manifests always reports the same cycle
|
||||
// rather than whichever one the map happened to hand over first.
|
||||
sort.Strings(codes)
|
||||
|
||||
var path []string
|
||||
var walk func(code string) error
|
||||
walk = func(code string) error {
|
||||
switch colour[code] {
|
||||
case grey:
|
||||
// Trim the path to where this code first appears, so the error
|
||||
// is the cycle and not the walk that reached it. grey is only
|
||||
// ever set together with the append below, and cleared together
|
||||
// with the matching trim, so the code is always on the path.
|
||||
cycle := append(slices.Clone(path[slices.Index(path, code):]), code)
|
||||
return fmt.Errorf("the declared dependencies form a cycle: %s",
|
||||
strings.Join(cycle, " -> "))
|
||||
case black:
|
||||
return nil
|
||||
}
|
||||
colour[code] = grey
|
||||
path = append(path, code)
|
||||
// In the order the manifest declared them, which is a fixed order
|
||||
// already - sorting here would only make the reported cycle harder
|
||||
// to line up against the manifest that caused it. The determinism
|
||||
// that matters comes from the sorted outer loop, because that one
|
||||
// walks a map.
|
||||
for _, r := range manifests[code].Requires {
|
||||
n := migration.NormalizeAppCode(r)
|
||||
if _, registered := manifests[n]; !registered {
|
||||
// Including the empty string, which Register rejects, so
|
||||
// no manifest is filed under it.
|
||||
continue
|
||||
}
|
||||
if err := walk(n); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
path = path[:len(path)-1]
|
||||
colour[code] = black
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, code := range codes {
|
||||
if err := walk(code); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadApps reads every sys_app row, keyed by app code.
|
||||
//
|
||||
// A database that has never had 1786700007000 applied has no such table, and
|
||||
// that is not an error here: `migrate status` has to keep working on a
|
||||
// database that has not been migrated at all, which is when it is most wanted.
|
||||
// A nil map is the honest answer there, and the caller prints what it always
|
||||
// printed.
|
||||
func loadApps(db *gorm.DB) (map[string]adminmodels.SysApp, error) {
|
||||
if !db.Migrator().HasTable(&adminmodels.SysApp{}) {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []adminmodels.SysApp
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("reading sys_app: %w", err)
|
||||
}
|
||||
out := make(map[string]adminmodels.SysApp, len(rows))
|
||||
for _, r := range rows {
|
||||
// An application cannot be filed under the empty code or the one
|
||||
// reserved for the framework - Register rejects both - so a row
|
||||
// carrying either was not written by an install. Dropping it here
|
||||
// is the one place that settles it: every reader of this map would
|
||||
// otherwise have to decide separately, and `migrate status` would
|
||||
// merge such a row into the framework's own group.
|
||||
if r.AppCode == "" || r.AppCode == migration.FrameworkAppCode {
|
||||
continue
|
||||
}
|
||||
out[r.AppCode] = r
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pendingFor is the authoritative answer to "what is left to apply", and it
|
||||
// is recomputed every time rather than stored: what is registered in this
|
||||
// process, minus what sys_migration says has run. sys_app.failed_version is a
|
||||
// snapshot of what this returned once and may be stale by now; nothing may
|
||||
// read it to decide this.
|
||||
func pendingFor(eng engine, code string) ([]string, error) {
|
||||
entries, err := eng.Status()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.AppCode == code && e.Registered && !e.Applied {
|
||||
out = append(out, e.Version)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// beginInstall is phase A. It refreshes every descriptive column from the
|
||||
// manifest, because those are the manifest's to say and the row is only a
|
||||
// copy, and it clears the two diagnostic columns so a stale failure from a
|
||||
// previous attempt cannot be read as this one's.
|
||||
func beginInstall(db *gorm.DB, row *adminmodels.SysApp, m app.Manifest, code string, found bool, now time.Time) error {
|
||||
row.AppCode = code
|
||||
row.Name = m.Name
|
||||
row.Version = m.Version
|
||||
row.Description = m.Description
|
||||
row.Author = m.Author
|
||||
row.Requires = strings.Join(m.Requires, ",")
|
||||
row.Pricing = m.Pricing
|
||||
row.License = m.License
|
||||
row.Status = adminmodels.AppInstalling
|
||||
row.FailedVersion = ""
|
||||
row.LastError = ""
|
||||
row.UpdatedAt = now
|
||||
if !found {
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
return fmt.Errorf("recording the install attempt for %q: %w", code, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := db.Save(row).Error; err != nil {
|
||||
return fmt.Errorf("recording the install attempt for %q: %w", code, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markInstalled is the success half of phase C. installed_at is set once and
|
||||
// never moved: an upgrade keeps the time of the first install, which is what
|
||||
// the column is for.
|
||||
//
|
||||
// Computed here rather than with COALESCE so the statement is the same on all
|
||||
// four drivers this repository supports.
|
||||
func markInstalled(db *gorm.DB, code string, installedAt *time.Time, now time.Time) error {
|
||||
updates := map[string]any{
|
||||
"status": adminmodels.AppInstalled,
|
||||
"updated_at": now,
|
||||
}
|
||||
if installedAt == nil {
|
||||
updates["installed_at"] = now
|
||||
}
|
||||
err := db.Model(&adminmodels.SysApp{}).Where("app_code = ?", code).Updates(updates).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("recording %q as installed: %w", code, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markFailed is the other half. Both columns it writes are diagnostic text
|
||||
// for whoever reads the row; no code may branch on either one.
|
||||
func markFailed(db *gorm.DB, code, failedVersion string, cause error, now time.Time) error {
|
||||
updates := map[string]any{
|
||||
"status": adminmodels.AppFailed,
|
||||
"failed_version": truncate(failedVersion, 64),
|
||||
"last_error": truncate(cause.Error(), 255),
|
||||
"updated_at": now,
|
||||
}
|
||||
return db.Model(&adminmodels.SysApp{}).Where("app_code = ?", code).Updates(updates).Error
|
||||
}
|
||||
|
||||
// truncate cuts s to at most n runes, not bytes: these columns are declared in
|
||||
// characters, and a message that is partly Chinese would otherwise be cut in
|
||||
// the middle of one and stored as an invalid sequence.
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n])
|
||||
}
|
||||
|
||||
// reportInstall prints what happened, and says that the data is in place but
|
||||
// the code is not.
|
||||
//
|
||||
// That last sentence is not a pleasantry. Go links its applications at build
|
||||
// time and Vite resolves its import globs at build time, so installing an
|
||||
// application writes its menus, its APIs and its permissions and cannot make
|
||||
// one line of its code run. An operator who is not told that sees the menus
|
||||
// appear and reasonably concludes the thing is live.
|
||||
func reportInstall(w io.Writer, rep installReport) {
|
||||
if rep.NoOp {
|
||||
fmt.Fprintf(w, "%s %s is already installed; nothing to do\n", rep.Code, rep.Version)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case rep.Previous == "":
|
||||
fmt.Fprintf(w, "installed %s %s\n", rep.Code, rep.Version)
|
||||
case rep.Previous == rep.Version:
|
||||
fmt.Fprintf(w, "brought %s %s the rest of the way\n", rep.Code, rep.Version)
|
||||
default:
|
||||
fmt.Fprintf(w, "upgraded %s from %s to %s\n", rep.Code, rep.Previous, rep.Version)
|
||||
}
|
||||
if len(rep.Applied) > 0 {
|
||||
fmt.Fprintf(w, "applied %d migration(s): %s\n", len(rep.Applied), strings.Join(rep.Applied, ", "))
|
||||
} else {
|
||||
fmt.Fprintln(w, "no migration was outstanding; only sys_app was brought up to date")
|
||||
}
|
||||
fmt.Fprintln(w, "the database is up to date, but the application's code is not running yet:")
|
||||
fmt.Fprintln(w, "rebuild and restart the server before expecting its routes to answer.")
|
||||
}
|
||||
|
||||
// manifestFor finds the manifest an application registered for code.
|
||||
//
|
||||
// A code nothing registered is an error naming what is registered, for the
|
||||
// same reason exitUnlessAppRegistered exists: the alternative is telling an
|
||||
// operator who typed `install ordr` that there was nothing to do.
|
||||
// Takes the snapshot rather than reading it, so this lookup and the caller's
|
||||
// cycle check see the same set. Two calls to app.Snapshot() would also be two
|
||||
// deep copies of the registry for one install.
|
||||
func manifestFor(all map[string]app.Manifest, code string) (app.Manifest, error) {
|
||||
want := migration.NormalizeAppCode(code)
|
||||
if m, ok := all[want]; ok {
|
||||
return m, nil
|
||||
}
|
||||
codes := make([]string, 0, len(all))
|
||||
for c := range all {
|
||||
codes = append(codes, c)
|
||||
}
|
||||
sort.Strings(codes)
|
||||
if len(codes) == 0 {
|
||||
// Worth its own sentence: no application is compiled into this
|
||||
// binary at all, which is a different thing from having typed the
|
||||
// wrong one of several.
|
||||
return app.Manifest{}, fmt.Errorf(
|
||||
"no application registers a manifest in this binary, so %q cannot be installed; "+
|
||||
"an application has to be compiled in before it can be installed", want)
|
||||
}
|
||||
return app.Manifest{}, fmt.Errorf("no application registers the code %q; registered: %s",
|
||||
want, strings.Join(codes, ", "))
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/app"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
)
|
||||
|
||||
func newInstallDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&adminmodels.SysApp{}); err != nil {
|
||||
t.Fatalf("automigrate sys_app: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// fakeEngine stands in for the migration engine. The real one is a
|
||||
// package-level singleton with no exported constructor, so a test taking it
|
||||
// would share one registry with every other test in this process.
|
||||
type fakeEngine struct {
|
||||
entries []migration.StatusEntry
|
||||
// failWith, when set, is what MigrateApp returns instead of applying.
|
||||
failWith error
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeEngine) SetDb(*gorm.DB) {}
|
||||
|
||||
func (f *fakeEngine) Status() ([]migration.StatusEntry, error) {
|
||||
out := make([]migration.StatusEntry, len(f.entries))
|
||||
copy(out, f.entries)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeEngine) MigrateApp(code string) error {
|
||||
f.calls = append(f.calls, code)
|
||||
if f.failWith != nil {
|
||||
return f.failWith
|
||||
}
|
||||
for i := range f.entries {
|
||||
if f.entries[i].AppCode == code && f.entries[i].Registered {
|
||||
f.entries[i].Applied = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func orderManifest(version string) app.Manifest {
|
||||
return app.Manifest{
|
||||
Code: "order",
|
||||
Name: "Orders",
|
||||
Version: version,
|
||||
Description: "order management",
|
||||
Author: "go-admin",
|
||||
// No dependency by default: these tests are about installing, and a
|
||||
// declared requirement would make every one of them set up a second
|
||||
// application first. requiresInstalled has its own tests below.
|
||||
Requires: nil,
|
||||
Pricing: "free",
|
||||
License: "MIT",
|
||||
}
|
||||
}
|
||||
|
||||
// appRow writes one sys_app row: what another application looks like to the
|
||||
// installer, in whichever state the caller is testing against.
|
||||
func appRow(t *testing.T, db *gorm.DB, code string, status int) {
|
||||
t.Helper()
|
||||
if err := db.Create(&adminmodels.SysApp{
|
||||
AppCode: code, Name: code, Version: "1.0.0", Status: status,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seeding %q with status %d: %v", code, status, err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadRow(t *testing.T, db *gorm.DB, code string) adminmodels.SysApp {
|
||||
t.Helper()
|
||||
var row adminmodels.SysApp
|
||||
if err := db.Where("app_code = ?", code).First(&row).Error; err != nil {
|
||||
t.Fatalf("sys_app has no row for %q: %v", code, err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// A1: a first install records the app, at the version the manifest declares,
|
||||
// with every descriptive column copied from it.
|
||||
func TestInstallRecordsAFirstInstall(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
{Version: "order-1786800002000", AppCode: "order", Registered: true},
|
||||
{Version: "crm-1786800001000", AppCode: "crm", Registered: true},
|
||||
}}
|
||||
|
||||
rep, err := install(db, eng, orderManifest("1.0.0"))
|
||||
if err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
if rep.NoOp {
|
||||
t.Error("a first install reported nothing to do")
|
||||
}
|
||||
if got, want := len(rep.Applied), 2; got != want {
|
||||
t.Errorf("applied %v, want %d versions", rep.Applied, want)
|
||||
}
|
||||
// Only this app's migrations, not every pending one in the process.
|
||||
if len(eng.calls) != 1 || eng.calls[0] != "order" {
|
||||
t.Errorf("MigrateApp calls = %v", eng.calls)
|
||||
}
|
||||
|
||||
row := loadRow(t, db, "order")
|
||||
if row.Status != adminmodels.AppInstalled {
|
||||
t.Errorf("status = %d, want installed", row.Status)
|
||||
}
|
||||
if row.Version != "1.0.0" {
|
||||
t.Errorf("version = %q", row.Version)
|
||||
}
|
||||
if row.InstalledAt == nil {
|
||||
t.Error("installed_at was not set")
|
||||
}
|
||||
if row.Name != "Orders" || row.Author != "go-admin" || row.Description != "order management" {
|
||||
t.Errorf("descriptive columns not copied from the manifest: %+v", row)
|
||||
}
|
||||
if row.Pricing != "free" || row.License != "MIT" {
|
||||
t.Errorf("the reserved fields were not carried through: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
// A2: installing the same version again is a no-op, and says so.
|
||||
func TestInstallIsANoOpAtTheSameVersion(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
}}
|
||||
if _, err := install(db, eng, orderManifest("1.0.0")); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
before := loadRow(t, db, "order")
|
||||
|
||||
rep, err := install(db, eng, orderManifest("1.0.0"))
|
||||
if err != nil {
|
||||
t.Fatalf("second install: %v", err)
|
||||
}
|
||||
if !rep.NoOp {
|
||||
t.Error("installing the same version again was not reported as a no-op")
|
||||
}
|
||||
if len(eng.calls) != 1 {
|
||||
t.Errorf("the engine was driven again: %v", eng.calls)
|
||||
}
|
||||
after := loadRow(t, db, "order")
|
||||
if !after.UpdatedAt.Equal(before.UpdatedAt) {
|
||||
t.Error("a no-op rewrote the row")
|
||||
}
|
||||
var n int64
|
||||
db.Model(&adminmodels.SysApp{}).Count(&n)
|
||||
if n != 1 {
|
||||
t.Errorf("sys_app has %d rows, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A no-op is only a no-op when nothing is outstanding. A row that says
|
||||
// installed while a migration of its has never run is the case sys_app must
|
||||
// not be believed over sys_migration.
|
||||
func TestInstallRunsWhenTheRowSaysInstalledButAMigrationIsPending(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true, Applied: true},
|
||||
}}
|
||||
if _, err := install(db, eng, orderManifest("1.0.0")); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
|
||||
// A second version of the same app appears - the app was rebuilt with
|
||||
// one more migration file, without its version changing.
|
||||
eng.entries = append(eng.entries, migration.StatusEntry{
|
||||
Version: "order-1786800002000", AppCode: "order", Registered: true,
|
||||
})
|
||||
|
||||
rep, err := install(db, eng, orderManifest("1.0.0"))
|
||||
if err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
if rep.NoOp {
|
||||
t.Fatal("an outstanding migration was reported as nothing to do")
|
||||
}
|
||||
if len(rep.Applied) != 1 || rep.Applied[0] != "order-1786800002000" {
|
||||
t.Errorf("applied = %v", rep.Applied)
|
||||
}
|
||||
}
|
||||
|
||||
// A9: an upgrade is in place. installed_at is the first install's, not this
|
||||
// one's.
|
||||
func TestInstallUpgradesInPlaceAndKeepsTheFirstInstallTime(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
}}
|
||||
if _, err := install(db, eng, orderManifest("1.0.0")); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
first := loadRow(t, db, "order")
|
||||
if first.InstalledAt == nil {
|
||||
t.Fatal("installed_at was not set by the first install")
|
||||
}
|
||||
|
||||
eng.entries = append(eng.entries, migration.StatusEntry{
|
||||
Version: "order-1786800002000", AppCode: "order", Registered: true,
|
||||
})
|
||||
rep, err := install(db, eng, orderManifest("2.0.0"))
|
||||
if err != nil {
|
||||
t.Fatalf("upgrade: %v", err)
|
||||
}
|
||||
if rep.Previous != "1.0.0" {
|
||||
t.Errorf("previous = %q, want 1.0.0", rep.Previous)
|
||||
}
|
||||
|
||||
row := loadRow(t, db, "order")
|
||||
if row.Version != "2.0.0" {
|
||||
t.Errorf("version = %q, want 2.0.0", row.Version)
|
||||
}
|
||||
if row.Status != adminmodels.AppInstalled {
|
||||
t.Errorf("status = %d, want installed", row.Status)
|
||||
}
|
||||
if !row.InstalledAt.Equal(*first.InstalledAt) {
|
||||
t.Errorf("installed_at moved from %v to %v; an upgrade keeps the first install's time",
|
||||
first.InstalledAt, row.InstalledAt)
|
||||
}
|
||||
}
|
||||
|
||||
// A10: a downgrade is refused, and refused before anything is written.
|
||||
func TestInstallRefusesADowngrade(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
}}
|
||||
if _, err := install(db, eng, orderManifest("2.0.0")); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
before := loadRow(t, db, "order")
|
||||
|
||||
_, err := install(db, eng, orderManifest("1.0.0"))
|
||||
if err == nil {
|
||||
t.Fatal("a downgrade was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "downgrade") {
|
||||
t.Errorf("error = %q, it has to say what it refused", err)
|
||||
}
|
||||
after := loadRow(t, db, "order")
|
||||
if after.Version != before.Version || after.Status != before.Status {
|
||||
t.Errorf("the refused downgrade still wrote to the row: %+v -> %+v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// A5: a failing migration leaves a row that says so, and says where.
|
||||
func TestInstallRecordsAFailure(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
boom := errors.New("the seed hit a duplicate")
|
||||
eng := &fakeEngine{
|
||||
entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
},
|
||||
failWith: &migration.VersionFailure{Version: "order-1786800001000", Err: boom},
|
||||
}
|
||||
|
||||
_, err := install(db, eng, orderManifest("1.0.0"))
|
||||
if err == nil {
|
||||
t.Fatal("a failed install reported success")
|
||||
}
|
||||
if !errors.Is(err, boom) {
|
||||
t.Errorf("the cause is not reachable: %v", err)
|
||||
}
|
||||
|
||||
row := loadRow(t, db, "order")
|
||||
if row.Status != adminmodels.AppFailed {
|
||||
t.Errorf("status = %d, want failed", row.Status)
|
||||
}
|
||||
if row.FailedVersion != "order-1786800001000" {
|
||||
t.Errorf("failed_version = %q", row.FailedVersion)
|
||||
}
|
||||
if !strings.Contains(row.LastError, "duplicate") {
|
||||
t.Errorf("last_error = %q", row.LastError)
|
||||
}
|
||||
if row.InstalledAt != nil {
|
||||
t.Error("installed_at was set by an install that failed")
|
||||
}
|
||||
}
|
||||
|
||||
// A failed install is retried by running it again - not by any special
|
||||
// command, and without the previous attempt's diagnostics surviving into a
|
||||
// row that now says installed.
|
||||
func TestInstallResumesAfterAFailure(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{
|
||||
entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
},
|
||||
failWith: &migration.VersionFailure{Version: "order-1786800001000", Err: errors.New("boom")},
|
||||
}
|
||||
if _, err := install(db, eng, orderManifest("1.0.0")); err == nil {
|
||||
t.Fatal("the first attempt did not fail")
|
||||
}
|
||||
|
||||
eng.failWith = nil
|
||||
rep, err := install(db, eng, orderManifest("1.0.0"))
|
||||
if err != nil {
|
||||
t.Fatalf("retry: %v", err)
|
||||
}
|
||||
if rep.NoOp {
|
||||
t.Error("a failed row was treated as installed")
|
||||
}
|
||||
|
||||
row := loadRow(t, db, "order")
|
||||
if row.Status != adminmodels.AppInstalled {
|
||||
t.Errorf("status = %d, want installed", row.Status)
|
||||
}
|
||||
if row.FailedVersion != "" || row.LastError != "" {
|
||||
t.Errorf("the previous failure survived onto a row that now says installed: %q / %q",
|
||||
row.FailedVersion, row.LastError)
|
||||
}
|
||||
if row.InstalledAt == nil {
|
||||
t.Error("installed_at was not set by the attempt that succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// A row stuck at installing - the process was killed partway - is not
|
||||
// installed, and must not be mistaken for it.
|
||||
func TestInstallRetriesARowStuckAtInstalling(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
if err := db.Create(&adminmodels.SysApp{
|
||||
AppCode: "order", Name: "Orders", Version: "1.0.0",
|
||||
Status: adminmodels.AppInstalling,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true, Applied: true},
|
||||
}}
|
||||
|
||||
rep, err := install(db, eng, orderManifest("1.0.0"))
|
||||
if err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
if rep.NoOp {
|
||||
t.Fatal("a row stuck at installing was reported as already installed")
|
||||
}
|
||||
if row := loadRow(t, db, "order"); row.Status != adminmodels.AppInstalled {
|
||||
t.Errorf("status = %d, want installed", row.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRejectsTheFrameworkCode(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
m := orderManifest("1.0.0")
|
||||
m.Code = migration.FrameworkAppCode
|
||||
_, err := install(db, &fakeEngine{}, m)
|
||||
if err == nil {
|
||||
t.Fatal("the framework was installed as an application")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "migrate") {
|
||||
t.Errorf("error = %q, it should point at the command that does this", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRefusesAnUnparseableRecordedVersion(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
if err := db.Create(&adminmodels.SysApp{
|
||||
AppCode: "order", Name: "Orders", Version: "v1.0", Status: adminmodels.AppInstalled,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
_, err := install(db, &fakeEngine{}, orderManifest("1.0.0"))
|
||||
if err == nil {
|
||||
t.Fatal("an unparseable recorded version was compared anyway")
|
||||
}
|
||||
row := loadRow(t, db, "order")
|
||||
if row.Status != adminmodels.AppInstalled || row.Version != "v1.0" {
|
||||
t.Errorf("the row was overwritten before the comparison failed: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
// A7: the report has to say the code is not running yet. Menus appearing is
|
||||
// exactly what makes an operator think it is.
|
||||
func TestReportInstallSaysTheCodeIsNotRunningYet(t *testing.T) {
|
||||
var out strings.Builder
|
||||
reportInstall(&out, installReport{Code: "order", Version: "1.0.0", Applied: []string{"order-1786800001000"}})
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "rebuild") || !strings.Contains(got, "restart") {
|
||||
t.Errorf("the report does not say the binary has to be rebuilt: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "order-1786800001000") {
|
||||
t.Errorf("the report does not name what it applied: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportInstallOnANoOp(t *testing.T) {
|
||||
var out strings.Builder
|
||||
reportInstall(&out, installReport{Code: "order", Version: "1.0.0", NoOp: true})
|
||||
if !strings.Contains(out.String(), "already installed") {
|
||||
t.Errorf("output = %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// last_error is a varchar(255) declared in characters. A message that is
|
||||
// partly Chinese would be cut mid-rune by a byte-wise truncation and stored
|
||||
// as an invalid sequence.
|
||||
func TestTruncateCutsRunesNotBytes(t *testing.T) {
|
||||
s := strings.Repeat("迁", 300)
|
||||
got := truncate(s, 255)
|
||||
if n := len([]rune(got)); n != 255 {
|
||||
t.Errorf("kept %d runes, want 255", n)
|
||||
}
|
||||
if !strings.HasPrefix(s, got) {
|
||||
t.Error("truncation did not cut at a rune boundary")
|
||||
}
|
||||
if short := truncate("ok", 255); short != "ok" {
|
||||
t.Errorf("a short message was altered: %q", short)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallNeedsSysApp(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
_, err = install(db, &fakeEngine{}, orderManifest("1.0.0"))
|
||||
if err == nil {
|
||||
t.Fatal("install ran against a database with no sys_app")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "migrate") {
|
||||
t.Errorf("error = %q, it should say what to run first", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The code written to sys_app and handed to the engine is the normalized one.
|
||||
// A manifest whose Code was typed with different case or stray spaces has to
|
||||
// land on the same identity migration.ForApp and seed.SeedMenus already use,
|
||||
// or the row and the migrations it stands for are filed under two names.
|
||||
func TestInstallNormalizesTheAppCode(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
}}
|
||||
m := orderManifest("1.0.0")
|
||||
m.Code = " Order "
|
||||
|
||||
rep, err := install(db, eng, m)
|
||||
if err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
if rep.Code != "order" {
|
||||
t.Errorf("reported code = %q, want order", rep.Code)
|
||||
}
|
||||
if len(eng.calls) != 1 || eng.calls[0] != "order" {
|
||||
t.Errorf("the engine was asked for %v, want [order]", eng.calls)
|
||||
}
|
||||
// The row has to be findable by the normalized code, which is what every
|
||||
// other table in this batch is keyed by.
|
||||
row := loadRow(t, db, "order")
|
||||
if row.AppCode != "order" {
|
||||
t.Errorf("app_code = %q", row.AppCode)
|
||||
}
|
||||
if len(rep.Applied) != 1 {
|
||||
t.Errorf("applied = %v; the normalized code has to match what Status reports", rep.Applied)
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest's dependency list is stored as it was declared, in the CSV
|
||||
// shape sys_app.requires carries.
|
||||
func TestInstallStoresTheDeclaredRequires(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
appRow(t, db, "crm", adminmodels.AppInstalled)
|
||||
appRow(t, db, "billing", adminmodels.AppInstalled)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
}}
|
||||
m := orderManifest("1.0.0")
|
||||
m.Requires = []string{"crm", "billing"}
|
||||
|
||||
if _, err := install(db, eng, m); err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
if row := loadRow(t, db, "order"); row.Requires != "crm,billing" {
|
||||
t.Errorf("requires = %q, want the manifest's list as CSV", row.Requires)
|
||||
}
|
||||
}
|
||||
|
||||
// An application is not installed for you because something else names it.
|
||||
// "Install this" would otherwise mean "and everything it happens to name, and
|
||||
// everything those name".
|
||||
func TestInstallRefusesWhenADependencyIsNotInstalled(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
}}
|
||||
m := orderManifest("1.0.0")
|
||||
m.Requires = []string{"crm"}
|
||||
|
||||
_, err := install(db, eng, m)
|
||||
if err == nil {
|
||||
t.Fatal("an application with an uninstalled dependency was installed")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "crm") || !strings.Contains(err.Error(), "not installed") {
|
||||
t.Errorf("error = %q, it has to name what is missing and why", err)
|
||||
}
|
||||
if len(eng.calls) != 0 {
|
||||
t.Errorf("the engine ran anyway: %v", eng.calls)
|
||||
}
|
||||
// Refused before phase A, so a refusal leaves nothing behind.
|
||||
if n := count(t, db, "sys_app", "app_code = ?", "order"); n != 0 {
|
||||
t.Errorf("a refused install wrote %d sys_app row(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A dependency whose own install failed or never finished is not a dependency
|
||||
// that is there, and the two say which they are - one sends you to install it,
|
||||
// the other to look at why.
|
||||
func TestInstallRefusesWhenADependencyIsNotFinished(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
want string
|
||||
}{
|
||||
{"failed", adminmodels.AppFailed, "its install failed"},
|
||||
{"installing", adminmodels.AppInstalling, "did not finish"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
appRow(t, db, "crm", tc.status)
|
||||
m := orderManifest("1.0.0")
|
||||
m.Requires = []string{"crm"}
|
||||
_, err := install(db, &fakeEngine{}, m)
|
||||
if err == nil {
|
||||
t.Fatal("the dependency was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("error = %q, want it to say %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallAcceptsASatisfiedDependency(t *testing.T) {
|
||||
db := newInstallDB(t)
|
||||
appRow(t, db, "crm", adminmodels.AppInstalled)
|
||||
eng := &fakeEngine{entries: []migration.StatusEntry{
|
||||
{Version: "order-1786800001000", AppCode: "order", Registered: true},
|
||||
}}
|
||||
m := orderManifest("1.0.0")
|
||||
m.Requires = []string{"crm"}
|
||||
|
||||
if _, err := install(db, eng, m); err != nil {
|
||||
t.Fatalf("install: %v", err)
|
||||
}
|
||||
if row := loadRow(t, db, "order"); row.Status != adminmodels.AppInstalled {
|
||||
t.Errorf("status = %d, want installed", row.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDependencyCycleIsRefused(t *testing.T) {
|
||||
manifests := map[string]app.Manifest{
|
||||
"a": {Code: "a", Requires: []string{"b"}},
|
||||
"b": {Code: "b", Requires: []string{"c"}},
|
||||
"c": {Code: "c", Requires: []string{"a"}},
|
||||
}
|
||||
err := refuseOnDependencyCycle(manifests)
|
||||
if err == nil {
|
||||
t.Fatal("a cycle was accepted")
|
||||
}
|
||||
// The error is the cycle, not the walk that reached it.
|
||||
if !strings.Contains(err.Error(), "a -> b -> c -> a") {
|
||||
t.Errorf("error = %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A cycle between two applications neither of which is being installed is
|
||||
// still an authoring mistake, and the day somebody installs into it is the
|
||||
// worse time to find out.
|
||||
func TestDependencyCycleIsRefusedEvenAwayFromTheTarget(t *testing.T) {
|
||||
manifests := map[string]app.Manifest{
|
||||
"order": {Code: "order"},
|
||||
"x": {Code: "x", Requires: []string{"y"}},
|
||||
"y": {Code: "y", Requires: []string{"x"}},
|
||||
}
|
||||
if err := refuseOnDependencyCycle(manifests); err == nil {
|
||||
t.Fatal("a cycle away from the target was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDependencyGraphWithoutACycle(t *testing.T) {
|
||||
manifests := map[string]app.Manifest{
|
||||
"a": {Code: "a", Requires: []string{"b", "c"}},
|
||||
"b": {Code: "b", Requires: []string{"c"}},
|
||||
"c": {Code: "c"},
|
||||
// Naming something that is not registered is not a cycle. Whether it
|
||||
// is installed is a question for the database, at install time.
|
||||
"d": {Code: "d", Requires: []string{"nowhere"}},
|
||||
}
|
||||
if err := refuseOnDependencyCycle(manifests); err != nil {
|
||||
t.Errorf("a graph with no cycle was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An application that names itself.
|
||||
func TestDependencyCycleOfOne(t *testing.T) {
|
||||
manifests := map[string]app.Manifest{"a": {Code: "a", Requires: []string{"a"}}}
|
||||
err := refuseOnDependencyCycle(manifests)
|
||||
if err == nil {
|
||||
t.Fatal("an application requiring itself was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "a -> a") {
|
||||
t.Errorf("error = %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The cycle reached from outside it. a is not part of anything circular; b
|
||||
// and c are. Reporting the walk instead of the cycle would name a as well,
|
||||
// and sending somebody to look at an application that is not involved is
|
||||
// the whole reason the path is trimmed.
|
||||
func TestDependencyCycleReportsOnlyTheCycleItReached(t *testing.T) {
|
||||
manifests := map[string]app.Manifest{
|
||||
"a": {Code: "a", Requires: []string{"b"}},
|
||||
"b": {Code: "b", Requires: []string{"c"}},
|
||||
"c": {Code: "c", Requires: []string{"b"}},
|
||||
}
|
||||
err := refuseOnDependencyCycle(manifests)
|
||||
if err == nil {
|
||||
t.Fatal("a cycle was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "b -> c -> b") {
|
||||
t.Errorf("error = %q, want just the cycle", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "a ->") {
|
||||
t.Errorf("the walk that reached the cycle was reported as part of it: %q", err)
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,28 @@ func (e *Migration) mergedEntries() map[string]versionEntry {
|
||||
return out
|
||||
}
|
||||
|
||||
// RegisteredVersions returns every migration version this binary registers,
|
||||
// sorted, without touching a database.
|
||||
//
|
||||
// Status answers a richer question - what is registered, what is applied, and
|
||||
// what is applied while nothing registers it - and needs a database to do it.
|
||||
// This is the half that can be asked of the process alone, which is what a
|
||||
// readiness check needs: the check holds the databases it is asking about, and
|
||||
// reusing Status would mean calling SetDb from a request handler, writing this
|
||||
// package's shared state from a request path.
|
||||
func (e *Migration) RegisteredVersions() []string {
|
||||
all := e.mergedEntries()
|
||||
out := make([]string, 0, len(all))
|
||||
for k := range all {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// RegisteredVersions reports what the process-wide registry holds.
|
||||
func RegisteredVersions() []string { return Migrate.RegisteredVersions() }
|
||||
|
||||
// StatusEntry is one row of migrate status.
|
||||
type StatusEntry struct {
|
||||
AppCode string
|
||||
@@ -262,12 +284,33 @@ func (e *Migration) Status() ([]StatusEntry, error) {
|
||||
}
|
||||
|
||||
// Migrate applies every registered migration that has not been applied yet,
|
||||
// across all apps. Existing callers are unaffected.
|
||||
func (e *Migration) Migrate() { e.run(allApps) }
|
||||
// across all apps.
|
||||
func (e *Migration) Migrate() error { return e.run(allApps) }
|
||||
|
||||
// MigrateApp applies only the migrations registered under appCode. Pass
|
||||
// FrameworkAppCode for the framework's own migrations.
|
||||
func (e *Migration) MigrateApp(appCode string) { e.run(AppFilter(appCode)) }
|
||||
func (e *Migration) MigrateApp(appCode string) error { return e.run(AppFilter(appCode)) }
|
||||
|
||||
// VersionFailure names the migration that failed.
|
||||
//
|
||||
// The caller that needs this is an installer recording which version an
|
||||
// install got stuck on. That is a diagnostic snapshot and nothing more: the
|
||||
// authoritative answer to "where does a retry resume" is always recomputed
|
||||
// by subtracting sys_migration's applied rows from what is registered, never
|
||||
// read back from anywhere it was stored. Which is exactly why this carries
|
||||
// the version rather than leaving the caller to infer it - inferring it
|
||||
// would produce "what is pending now", a different question that happens to
|
||||
// have the same answer most of the time.
|
||||
type VersionFailure struct {
|
||||
Version string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *VersionFailure) Error() string {
|
||||
return fmt.Sprintf("migration %s failed: %v", e.Version, e.Err)
|
||||
}
|
||||
|
||||
func (e *VersionFailure) Unwrap() error { return e.Err }
|
||||
|
||||
// NormalizeAppCode applies the same rule ForApp does, so a code typed on the
|
||||
// command line matches one written in an init().
|
||||
@@ -310,7 +353,15 @@ func (e *Migration) AppCodes() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *Migration) run(appCode string) {
|
||||
// run applies the pending migrations selected by appCode.
|
||||
//
|
||||
// It reports failure instead of ending the process. It used to call
|
||||
// log.Fatalf, which took the whole process down at the first failing
|
||||
// migration - so a caller had nowhere to record what happened, and a test
|
||||
// could not exercise a failing migration at all without killing the test
|
||||
// binary. The exit now lives at the command layer, where the exit code is
|
||||
// the command's business (see initDB in cmd/migrate/server.go).
|
||||
func (e *Migration) run(appCode string) error {
|
||||
all := e.mergedEntries()
|
||||
versions := make([]string, 0, len(all))
|
||||
entries := make(map[string]versionEntry, len(all))
|
||||
@@ -325,10 +376,14 @@ func (e *Migration) run(appCode string) {
|
||||
|
||||
// A mistyped --app would otherwise select nothing and report "no
|
||||
// migrations to apply", which reads exactly like "already up to date".
|
||||
//
|
||||
// The command layer rejects an unregistered code before any database
|
||||
// work (exitUnlessAppRegistered), so on that path this is unreachable.
|
||||
// It is reachable from an installer, which asks for one app by name and
|
||||
// must not be told that installing an app nothing registered succeeded.
|
||||
if appCode != allApps && len(versions) == 0 {
|
||||
log.Printf("no migrations are registered for app %q; registered: %s",
|
||||
return fmt.Errorf("no migrations are registered for app %q; registered: %s",
|
||||
DisplayAppCode(appCode), strings.Join(e.AppCodes(), ", "))
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
@@ -337,7 +392,7 @@ func (e *Migration) run(appCode string) {
|
||||
for _, v := range versions {
|
||||
err = e.db.Table("sys_migration").Where("version = ?", v).Count(&count).Error
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
return fmt.Errorf("checking whether migration %s was applied: %w", v, err)
|
||||
}
|
||||
if count > 0 {
|
||||
// Already applied. This used to print the bare count, so a mature
|
||||
@@ -347,7 +402,7 @@ func (e *Migration) run(appCode string) {
|
||||
}
|
||||
log.Printf("applying migration %s", v)
|
||||
if err = entries[v].fn(e.db.Debug(), v); err != nil {
|
||||
log.Fatalf("migration %s failed: %v", v, err)
|
||||
return &VersionFailure{Version: v, Err: err}
|
||||
}
|
||||
applied++
|
||||
}
|
||||
@@ -356,6 +411,7 @@ func (e *Migration) run(appCode string) {
|
||||
} else {
|
||||
log.Printf("applied %d migration(s)", applied)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// allApps is the sentinel run() takes to mean "do not filter". It is distinct
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"os"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -81,7 +79,9 @@ func TestForAppRecordsItsAppCode(t *testing.T) {
|
||||
m.ForApp("x").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
m.Migrate()
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("m.Migrate(): %v", err)
|
||||
}
|
||||
|
||||
rows := rowsByVersion(t, db)
|
||||
row, ok := rows["x-1786800001000"]
|
||||
@@ -104,7 +104,9 @@ func TestSetVersionStillRecordsTheFrameworkAsEmpty(t *testing.T) {
|
||||
m.SetVersion("1786700009000", func(db *gorm.DB, version string) error {
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
m.Migrate()
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("m.Migrate(): %v", err)
|
||||
}
|
||||
|
||||
rows := rowsByVersion(t, db)
|
||||
row, ok := rows["1786700009000"]
|
||||
@@ -136,7 +138,9 @@ func TestMigrateAppRunsOnlyThatApp(t *testing.T) {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
m.MigrateApp("x")
|
||||
if err := m.MigrateApp("x"); err != nil {
|
||||
t.Fatalf("m.MigrateApp(\"x\"): %v", err)
|
||||
}
|
||||
|
||||
if !ran["x"] {
|
||||
t.Error("x did not run")
|
||||
@@ -167,7 +171,9 @@ func TestMigrateAppCoreSelectsTheFramework(t *testing.T) {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
m.MigrateApp(FrameworkAppCode)
|
||||
if err := m.MigrateApp(FrameworkAppCode); err != nil {
|
||||
t.Fatalf("m.MigrateApp(FrameworkAppCode): %v", err)
|
||||
}
|
||||
|
||||
if !ran["core"] {
|
||||
t.Error("framework migration did not run")
|
||||
@@ -198,7 +204,9 @@ func TestMigrateRunsEveryApp(t *testing.T) {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
m.Migrate()
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("m.Migrate(): %v", err)
|
||||
}
|
||||
|
||||
// Namespacing puts every framework migration - bare digits - ahead of every
|
||||
// app migration, and orders apps by code rather than by whose timestamp
|
||||
@@ -232,7 +240,9 @@ func TestNamespacingKeepsTwoAppsWithTheSameTimestampApart(t *testing.T) {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
}
|
||||
m.Migrate()
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("m.Migrate(): %v", err)
|
||||
}
|
||||
|
||||
if ran != 2 {
|
||||
t.Errorf("ran %d migrations, want 2", ran)
|
||||
@@ -357,11 +367,11 @@ func TestFailedMigrationLeavesNoRecord(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
// run() calls log.Fatal on failure, which would take the test binary with
|
||||
// it, so drive the registered function directly - the point here is the
|
||||
// transaction boundary, not the scheduler.
|
||||
entry := m.version["crm-1786800001000"]
|
||||
if err := entry.fn(db, "crm-1786800001000"); err == nil {
|
||||
// Driven through the scheduler, not by calling the registered function
|
||||
// directly. That workaround was here because run() called log.Fatal and
|
||||
// would have taken the test binary with it, which also meant nothing
|
||||
// covered what the scheduler does with a failure.
|
||||
if err := m.MigrateApp("crm"); err == nil {
|
||||
t.Fatal("migration reported success")
|
||||
}
|
||||
if rows := rowsByVersion(t, db); len(rows) != 0 {
|
||||
@@ -369,6 +379,49 @@ func TestFailedMigrationLeavesNoRecord(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An installer records which version an attempt got stuck on. It gets that
|
||||
// from the error rather than by asking the database what is still pending,
|
||||
// which is a different question - see VersionFailure.
|
||||
func TestRunReportsWhichVersionFailed(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := newMigration()
|
||||
m.SetDb(db)
|
||||
|
||||
// Two versions, and the first one succeeds: the failure has to name the
|
||||
// one that actually failed, which a report that just names the app, or
|
||||
// the first version it looked at, would get wrong.
|
||||
m.ForApp("crm").SetVersion("1786800001000", func(db *gorm.DB, version, appCode string) error {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
m.ForApp("crm").SetVersion("1786800002000", func(db *gorm.DB, version, appCode string) error {
|
||||
return errTestMigrationFailed
|
||||
})
|
||||
|
||||
err := m.MigrateApp("crm")
|
||||
if err == nil {
|
||||
t.Fatal("MigrateApp reported success")
|
||||
}
|
||||
var vf *VersionFailure
|
||||
if !errors.As(err, &vf) {
|
||||
t.Fatalf("error is %T, want *VersionFailure: %v", err, err)
|
||||
}
|
||||
if vf.Version != "crm-1786800002000" {
|
||||
t.Errorf("failed version = %q, want crm-1786800002000", vf.Version)
|
||||
}
|
||||
if !errors.Is(err, errTestMigrationFailed) {
|
||||
t.Errorf("the cause is not reachable through the wrapper: %v", err)
|
||||
}
|
||||
// The one that succeeded before it stays recorded: a retry must not run
|
||||
// it again.
|
||||
rows := rowsByVersion(t, db)
|
||||
if _, ok := rows["crm-1786800001000"]; !ok {
|
||||
t.Errorf("the migration that succeeded was not recorded: %v", rows)
|
||||
}
|
||||
if _, ok := rows["crm-1786800002000"]; ok {
|
||||
t.Errorf("the migration that failed was recorded: %v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
var errTestMigrationFailed = &testError{"boom"}
|
||||
|
||||
type testError struct{ s string }
|
||||
@@ -389,17 +442,18 @@ func TestMigrateAppOnAnUnknownCodeSaysSo(t *testing.T) {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
log.SetOutput(&buf)
|
||||
t.Cleanup(func() { log.SetOutput(os.Stderr) })
|
||||
|
||||
m.MigrateApp("crmm")
|
||||
|
||||
if !strings.Contains(buf.String(), `no migrations are registered for app "crmm"`) {
|
||||
t.Errorf("output = %q", buf.String())
|
||||
// Reported as an error rather than a log line, so an installer asking
|
||||
// for one app by name cannot be told that installing an app nothing
|
||||
// registered succeeded.
|
||||
err := m.MigrateApp("crmm")
|
||||
if err == nil {
|
||||
t.Fatal("a typo reported success")
|
||||
}
|
||||
if !strings.Contains(buf.String(), "registered: core, crm") {
|
||||
t.Errorf("the message must list what is registered; got %q", buf.String())
|
||||
if !strings.Contains(err.Error(), `no migrations are registered for app "crmm"`) {
|
||||
t.Errorf("error = %q", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "registered: core, crm") {
|
||||
t.Errorf("the message must list what is registered; got %q", err)
|
||||
}
|
||||
if rows := rowsByVersion(t, db); len(rows) != 0 {
|
||||
t.Errorf("a typo ran %v", rows)
|
||||
@@ -425,7 +479,9 @@ func TestMergedEntriesRunsAContractRegisteredAppMigration(t *testing.T) {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
m.Migrate()
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("m.Migrate(): %v", err)
|
||||
}
|
||||
|
||||
if !ran {
|
||||
t.Fatal("contract-registered migration did not run")
|
||||
@@ -466,7 +522,9 @@ func TestMergedEntriesStatusIncludesContractRegisteredMigrations(t *testing.T) {
|
||||
t.Fatalf("pending contract entry = %+v (ok=%v)", e, ok)
|
||||
}
|
||||
|
||||
m.Migrate()
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("m.Migrate(): %v", err)
|
||||
}
|
||||
|
||||
entries, err = m.Status()
|
||||
if err != nil {
|
||||
@@ -522,7 +580,9 @@ func TestMergedEntriesMigrateAppRunsOnlyThatContractApp(t *testing.T) {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
m.MigrateApp("order")
|
||||
if err := m.MigrateApp("order"); err != nil {
|
||||
t.Fatalf("m.MigrateApp(\"order\"): %v", err)
|
||||
}
|
||||
|
||||
if !ran["order"] {
|
||||
t.Error("order did not run")
|
||||
@@ -552,7 +612,9 @@ func TestMergedEntriesHostRegistrationWinsOnKeyCollision(t *testing.T) {
|
||||
return recordFor(db, version, appCode)
|
||||
})
|
||||
|
||||
m.Migrate()
|
||||
if err := m.Migrate(); err != nil {
|
||||
t.Fatalf("m.Migrate(): %v", err)
|
||||
}
|
||||
|
||||
if !hostRan {
|
||||
t.Error("host registration did not run")
|
||||
|
||||
@@ -13,4 +13,4 @@ type SysApi struct {
|
||||
|
||||
func (SysApi) TableName() string {
|
||||
return "sys_api"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
package models
|
||||
|
||||
type SysMenu struct {
|
||||
MenuId int `json:"menuId" gorm:"primaryKey;autoIncrement"`
|
||||
MenuName string `json:"menuName" gorm:"size:128;"`
|
||||
Title string `json:"title" gorm:"size:128;"`
|
||||
Icon string `json:"icon" gorm:"size:128;"`
|
||||
Path string `json:"path" gorm:"size:128;"`
|
||||
Paths string `json:"paths" gorm:"size:128;"`
|
||||
MenuType string `json:"menuType" gorm:"size:1;"`
|
||||
Action string `json:"action" gorm:"size:16;"`
|
||||
Permission string `json:"permission" gorm:"size:255;"`
|
||||
ParentId int `json:"parentId" gorm:"size:11;"`
|
||||
NoCache bool `json:"noCache" gorm:"size:8;"`
|
||||
Breadcrumb string `json:"breadcrumb" gorm:"size:255;"`
|
||||
Component string `json:"component" gorm:"size:255;"`
|
||||
Sort int `json:"sort" gorm:"size:4;"`
|
||||
Visible string `json:"visible" gorm:"size:1;"`
|
||||
IsFrame string `json:"isFrame" gorm:"size:1;DEFAULT:0;"`
|
||||
SysApi []SysApi `json:"sysApi" gorm:"many2many:sys_menu_api_rule"`
|
||||
MenuId int `json:"menuId" gorm:"primaryKey;autoIncrement"`
|
||||
MenuName string `json:"menuName" gorm:"size:128;"`
|
||||
Title string `json:"title" gorm:"size:128;"`
|
||||
Icon string `json:"icon" gorm:"size:128;"`
|
||||
Path string `json:"path" gorm:"size:128;"`
|
||||
Paths string `json:"paths" gorm:"size:128;"`
|
||||
MenuType string `json:"menuType" gorm:"size:1;"`
|
||||
Action string `json:"action" gorm:"size:16;"`
|
||||
Permission string `json:"permission" gorm:"size:255;"`
|
||||
ParentId int `json:"parentId" gorm:"size:11;"`
|
||||
NoCache bool `json:"noCache" gorm:"size:8;"`
|
||||
Breadcrumb string `json:"breadcrumb" gorm:"size:255;"`
|
||||
Component string `json:"component" gorm:"size:255;"`
|
||||
Sort int `json:"sort" gorm:"size:4;"`
|
||||
Visible string `json:"visible" gorm:"size:1;"`
|
||||
IsFrame string `json:"isFrame" gorm:"size:1;DEFAULT:0;"`
|
||||
SysApi []SysApi `json:"sysApi" gorm:"many2many:sys_menu_api_rule"`
|
||||
ControlBy
|
||||
ModelTime
|
||||
}
|
||||
|
||||
func (SysMenu) TableName() string {
|
||||
return "sys_menu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,4 @@ type SysPost struct {
|
||||
|
||||
func (SysPost) TableName() string {
|
||||
return "sys_post"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,4 @@ type SysRole struct {
|
||||
|
||||
func (SysRole) TableName() string {
|
||||
return "sys_role"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Create sys_app (PRD 008 F2) and sys_app_casbin_grant (F4/F6's casbin
|
||||
// attribution ledger - see the design doc's (docs-prd/008-应用清单与安装器/
|
||||
// 数据库变更.md) §2.2/§3 for why casbin_rule itself is not touched:
|
||||
// gorm-adapter's SavePolicyCtx truncates and reloads that table from its
|
||||
// in-memory model, and any column this migration added to it would be
|
||||
// silently zeroed the first time anything calls SavePolicy.
|
||||
//
|
||||
// Ordered after 1786700003000 (the soft-delete conversion), so importing
|
||||
// cmd/migrate/migration/models is banned here - see
|
||||
// schema_coverage_test.go's TestPostConversionMigrationsAvoidFrozenSeedModels.
|
||||
// Both new tables are AutoMigrate'd from their runtime model shape under
|
||||
// app/admin/models directly, which is also why neither one is added to
|
||||
// 1786700003000's frozen softDeleteTables list: neither embeds
|
||||
// common.ModelTime in the first place (see design doc §1.1).
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700007000AppRegistryTables)
|
||||
}
|
||||
|
||||
func _1786700007000AppRegistryTables(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Migrator().AutoMigrate(
|
||||
new(adminmodels.SysApp),
|
||||
new(adminmodels.SysAppCasbinGrant),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
common "go-admin/common/models"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
)
|
||||
|
||||
// postgresDB is defined in 1786700003000_soft_delete_marker_postgres_test.go
|
||||
// and shared across this package's PostgreSQL-only tests.
|
||||
//
|
||||
// This migration is plain AutoMigrate on two brand-new tables, unlike
|
||||
// 1786700003000's DROP INDEX (go-admin#919's actual defect), so there is no
|
||||
// dialect-specific SQL here for AutoMigrate itself to get wrong on
|
||||
// PostgreSQL specifically. What is worth a real PostgreSQL run is
|
||||
// 1786700008000's CONCAT()-based duplicate check next door - PostgreSQL has
|
||||
// had CONCAT() since 9.1, but it was never verified against a real server
|
||||
// until this file, only inferred from documentation - and the same
|
||||
// AutoMigrate call this test makes, so a schema/character-set mistake
|
||||
// AutoMigrate might make silently on a dialect nobody ran it against here
|
||||
// has somewhere to surface.
|
||||
func TestAppRegistryTablesAreCreatedOnPostgres(t *testing.T) {
|
||||
db := postgresDB(t)
|
||||
const version = "1786700007000-pg"
|
||||
cleanup := func() {
|
||||
db.Migrator().DropTable(&adminmodels.SysAppCasbinGrant{}, &adminmodels.SysApp{})
|
||||
// Only this test's own row, not the whole shared sys_migration
|
||||
// table: postgresDB points at a real, persistent database (unlike
|
||||
// the SQLite tests' fresh in-memory one per run), so a version left
|
||||
// behind by a previous run of this same binary collides with the
|
||||
// wrapper's own INSERT the next time this test runs.
|
||||
db.Exec("DELETE FROM sys_migration WHERE version = ?", version)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
cleanup()
|
||||
if err := db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatalf("automigrate sys_migration: %v", err)
|
||||
}
|
||||
|
||||
if err := _1786700007000AppRegistryTables(db, version); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&adminmodels.SysApp{AppCode: "order", Name: "Order", Version: "v1"}).Error; err != nil {
|
||||
t.Fatalf("insert sys_app: %v", err)
|
||||
}
|
||||
if err := db.Create(&adminmodels.SysApp{AppCode: "order", Name: "dup", Version: "v1"}).Error; err == nil {
|
||||
t.Fatal("a second sys_app row with the same app_code was accepted on PostgreSQL")
|
||||
}
|
||||
|
||||
grant := adminmodels.SysAppCasbinGrant{AppCode: "order", Ptype: "p", V0: "admin", V1: "/api/v1/order", V2: "GET"}
|
||||
if err := db.Create(&grant).Error; err != nil {
|
||||
t.Fatalf("insert sys_app_casbin_grant: %v", err)
|
||||
}
|
||||
dup := grant
|
||||
dup.Id = 0
|
||||
if err := db.Create(&dup).Error; err == nil {
|
||||
t.Fatal("a second sys_app_casbin_grant row with the same natural key was accepted on PostgreSQL")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
func openAppRegistryDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatalf("automigrate sys_migration: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// The migration has to build both tables and record itself as applied -
|
||||
// F2/F6's acceptance case is a row landing in either one, and neither is
|
||||
// possible if the table it belongs to was never created.
|
||||
func TestAppRegistryTablesAreCreated(t *testing.T) {
|
||||
db := openAppRegistryDB(t)
|
||||
|
||||
if err := _1786700007000AppRegistryTables(db, "1786700007000"); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
if !db.Migrator().HasTable(&adminmodels.SysApp{}) {
|
||||
t.Fatal("sys_app was not created")
|
||||
}
|
||||
if !db.Migrator().HasTable(&adminmodels.SysAppCasbinGrant{}) {
|
||||
t.Fatal("sys_app_casbin_grant was not created")
|
||||
}
|
||||
|
||||
// A row that exercises every column, not just HasTable/HasColumn -
|
||||
// AutoMigrate can build a column with the wrong type and still report
|
||||
// that it exists.
|
||||
if err := db.Create(&adminmodels.SysApp{
|
||||
AppCode: "order", Name: "Order", Version: "v1", Description: "d", Author: "a",
|
||||
Requires: "payment", Pricing: "free", License: "MIT", Status: 1,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert sys_app: %v", err)
|
||||
}
|
||||
if err := db.Create(&adminmodels.SysAppCasbinGrant{
|
||||
AppCode: "order", Ptype: "p", V0: "admin", V1: "/api/v1/order", V2: "GET",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("insert sys_app_casbin_grant: %v", err)
|
||||
}
|
||||
|
||||
var applied common.Migration
|
||||
if err := db.Where("version = ?", "1786700007000").First(&applied).Error; err != nil {
|
||||
t.Fatalf("sys_migration was not recorded: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Running it twice must be safe: DDL does not roll back on MySQL, so an
|
||||
// operator whose first attempt failed partway through has nothing to do but
|
||||
// run it again. This calls AutoMigrate directly rather than the wrapper,
|
||||
// which also inserts a sys_migration row that a second call would collide
|
||||
// on - a collision Migrate.run() itself prevents by never calling a
|
||||
// function twice for the same recorded version, so it is not this
|
||||
// migration's job to tolerate.
|
||||
func TestAppRegistryTablesAutoMigrateIsRepeatable(t *testing.T) {
|
||||
db := openAppRegistryDB(t)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := db.Migrator().AutoMigrate(
|
||||
new(adminmodels.SysApp),
|
||||
new(adminmodels.SysAppCasbinGrant),
|
||||
); err != nil {
|
||||
t.Fatalf("automigrate %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sys_app.app_code is the unique key G2 ("is app X installed") answers with
|
||||
// - a second row for the same app code must be rejected, not tolerated.
|
||||
func TestSysAppAppCodeIsUnique(t *testing.T) {
|
||||
db := openAppRegistryDB(t)
|
||||
if err := _1786700007000AppRegistryTables(db, "1786700007000"); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&adminmodels.SysApp{AppCode: "order", Name: "Order", Version: "v1"}).Error; err != nil {
|
||||
t.Fatalf("first insert: %v", err)
|
||||
}
|
||||
if err := db.Create(&adminmodels.SysApp{AppCode: "order", Name: "Order dup", Version: "v1"}).Error; err == nil {
|
||||
t.Fatal("a second sys_app row with the same app_code was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// sys_app_casbin_grant's unique index mirrors casbin_rule's own natural key
|
||||
// (ptype,v0..v5) exactly - see design doc §3. A duplicate grant for the
|
||||
// same rule must be rejected the same way gorm-adapter's own unique index
|
||||
// on casbin_rule would reject it.
|
||||
func TestSysAppCasbinGrantNaturalKeyIsUnique(t *testing.T) {
|
||||
db := openAppRegistryDB(t)
|
||||
if err := _1786700007000AppRegistryTables(db, "1786700007000"); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
grant := adminmodels.SysAppCasbinGrant{AppCode: "order", Ptype: "p", V0: "admin", V1: "/api/v1/order", V2: "GET"}
|
||||
if err := db.Create(&grant).Error; err != nil {
|
||||
t.Fatalf("first insert: %v", err)
|
||||
}
|
||||
dup := grant
|
||||
dup.Id = 0
|
||||
if err := db.Create(&dup).Error; err == nil {
|
||||
t.Fatal("a second sys_app_casbin_grant row with the same natural key was accepted")
|
||||
}
|
||||
|
||||
// A grant for a different app, but the identical casbin natural key, is
|
||||
// exactly the collision two applications granting the same api/role
|
||||
// pair would produce - the natural key has to be the one thing that
|
||||
// rejects it, app_code is descriptive only and not part of the index.
|
||||
other := grant
|
||||
other.Id = 0
|
||||
other.AppCode = "another-app"
|
||||
if err := db.Create(&other).Error; err == nil {
|
||||
t.Fatal("a duplicate natural key under a different app_code was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Give seed.SeedMenus's two write paths (seedApis, seedMenuTree in
|
||||
// app/admin/service/seed.go) a real natural key to check before inserting,
|
||||
// so a retried, partially-failed migration (see the design doc
|
||||
// docs-prd/008-应用清单与安装器/数据库变更.md §1.5/§1.6) does not insert the
|
||||
// same row twice. This has already happened in production once (duplicate
|
||||
// sys_menu/casbin_rule rows on the demo site), not a theoretical risk.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700008000SeedNaturalKeys)
|
||||
}
|
||||
|
||||
func _1786700008000SeedNaturalKeys(db *gorm.DB, version string) error {
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
}
|
||||
|
||||
// seedNaturalKeys is split out from the wrapper above so tests can call it
|
||||
// against a database that only has sys_menu/sys_api, without also standing
|
||||
// up sys_migration - and so it can be called more than once in the same
|
||||
// test to prove the re-run tolerance the doc comment above promises: DDL
|
||||
// does not roll back on MySQL, so an operator whose first attempt failed
|
||||
// partway through has nothing to do but run the whole migration again.
|
||||
func seedNaturalKeys(db *gorm.DB) error {
|
||||
m := db.Migrator()
|
||||
|
||||
// sys_menu.seed_code is a brand-new column: every existing row becomes
|
||||
// NULL, and NULL never collides in the unique index built below, so
|
||||
// this needs no pre-check.
|
||||
if !m.HasColumn(&adminmodels.SysMenu{}, "SeedCode") {
|
||||
if err := m.AddColumn(&adminmodels.SysMenu{}, "SeedCode"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !m.HasIndex(&adminmodels.SysMenu{}, "uk_sys_menu_app_seed_code_del") {
|
||||
if err := db.Exec(uniqueIndexOverNullable(db.Dialector.Name(),
|
||||
"uk_sys_menu_app_seed_code_del", "sys_menu",
|
||||
"app_code, seed_code, deleted_at", "seed_code"),
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// sys_api reuses existing, already-populated columns, which the demo
|
||||
// site has already proven can hold duplicates. Refuse rather than let
|
||||
// CREATE UNIQUE INDEX fail on an operator with no idea which rows to
|
||||
// reconcile - same shape as 1786700003000_soft_delete_marker.go's
|
||||
// refuseOnDuplicates.
|
||||
if err := refuseOnDuplicateApis(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if !m.HasIndex(&adminmodels.SysApi{}, "uk_sys_api_app_path_action_del") {
|
||||
if err := db.Exec(uniqueIndexOverNullable(db.Dialector.Name(),
|
||||
"uk_sys_api_app_path_action_del", "sys_api",
|
||||
"app_code, path, action, deleted_at", "path", "action"),
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// uniqueIndexOverNullable builds a CREATE UNIQUE INDEX whose key includes
|
||||
// columns that can be NULL, and makes it mean the same thing on all four
|
||||
// drivers this repository registers.
|
||||
//
|
||||
// Three of them treat two NULLs as different values, so any number of rows
|
||||
// missing one of these columns coexist under the index. SQL Server does not:
|
||||
// its unique index treats NULLs as equal and permits exactly one. The
|
||||
// unfiltered statement therefore fails there on any database with two rows
|
||||
// lacking a seed_code - which is every database, including a brand-new one,
|
||||
// because 1786700001000 seeds five menus and none of them has one:
|
||||
//
|
||||
// Msg 1505 ... duplicate key ... The duplicate key value is (, <NULL>, 0).
|
||||
//
|
||||
// Adding the filter on SQL Server takes the rows that carry no value out of
|
||||
// the index, which is what the other three do by not comparing their NULLs.
|
||||
// It is not added elsewhere: MySQL has no filtered index at all, and on
|
||||
// PostgreSQL and SQLite it would only restate what those engines already do.
|
||||
//
|
||||
// Only databases that have not applied this migration are affected, and no
|
||||
// SQL Server database can have: it could not get past this statement.
|
||||
//
|
||||
// Takes the dialect by name rather than the connection, so the statement it
|
||||
// builds for every driver can be checked without one of each running.
|
||||
func uniqueIndexOverNullable(dialect, name, table, columns string, nullable ...string) string {
|
||||
stmt := fmt.Sprintf("CREATE UNIQUE INDEX %s ON %s (%s)", name, table, columns)
|
||||
if dialect != "sqlserver" || len(nullable) == 0 {
|
||||
return stmt
|
||||
}
|
||||
preds := make([]string, 0, len(nullable))
|
||||
for _, c := range nullable {
|
||||
preds = append(preds, c+" IS NOT NULL")
|
||||
}
|
||||
return stmt + " WHERE " + strings.Join(preds, " AND ")
|
||||
}
|
||||
|
||||
// refuseOnDuplicateApis reports the (app_code, path, action) values that
|
||||
// would make the unique index impossible, rather than the index failing to
|
||||
// build and saying only that it did. Only live rows count: a soft-deleted
|
||||
// duplicate does not block the index it will never occupy a slot in.
|
||||
//
|
||||
// sys_api.path/action (app/admin/models/sys_api.go) carry no NOT NULL
|
||||
// constraint, and that stays true here on purpose: tightening it is an
|
||||
// independent, backward-incompatible change of its own - existing NULL
|
||||
// rows in a real database would need reconciling or backfilling before
|
||||
// ALTER TABLE ... NOT NULL could even run, which is a decision for
|
||||
// whoever owns that data, not something this migration should force as a
|
||||
// side effect of adding an unrelated index. So this function has to
|
||||
// tolerate NULL path/action rather than assume they cannot occur - see the
|
||||
// query below for how it does that without either crashing on them
|
||||
// (MySQL's CONCAT) or wrongly flagging them (GROUP BY's NULL-equals-NULL).
|
||||
//
|
||||
// The two are independent bugs that happened to share one root cause, and
|
||||
// SQLite's own test suite for this file would have caught neither on its
|
||||
// own: MySQL's CONCAT() returns NULL if any argument is NULL, which turned
|
||||
// a duplicate check against a NULL-holding library into "converting NULL
|
||||
// to string is unsupported" instead of a report - but SQLite's (and
|
||||
// PostgreSQL's) CONCAT() treats a NULL argument as an empty string
|
||||
// instead, so the exact same query never errors there no matter how it is
|
||||
// called. A suite that only ever ran on SQLite would report success for
|
||||
// both defects; only a real MySQL server surfaces the first one at all -
|
||||
// this migration's PostgreSQL-only sibling test file
|
||||
// (1786700008000_seed_natural_keys_postgres_test.go) rules out one more
|
||||
// dialect, but MySQL specifically has to be checked by hand, since this
|
||||
// repository's test suite has no MySQL service to run against in CI.
|
||||
func refuseOnDuplicateApis(db *gorm.DB) error {
|
||||
var dupes []string
|
||||
if err := db.Raw(
|
||||
// This has to agree with what the unique index it guards actually
|
||||
// enforces, not just with what looks like a duplicate at a glance.
|
||||
// Two different SQL rules collide on a NULL: GROUP BY treats two
|
||||
// NULLs as equal, so a naive query flags every pair of rows that
|
||||
// share a NULL path or action - even a pair with only one of the
|
||||
// two NULL, since GROUP BY's equality still holds on whichever
|
||||
// column both rows leave NULL - but a UNIQUE INDEX treats every
|
||||
// NULL as distinct from every other value, including another
|
||||
// NULL, so the index itself accepts every one of those pairs
|
||||
// without complaint. Excluding any row missing either column from
|
||||
// consideration entirely is what makes the two agree: a row
|
||||
// missing path, or missing action, or missing both, can never
|
||||
// violate the index no matter how many other rows are also
|
||||
// missing the same one, so none of them belong in this count.
|
||||
//
|
||||
// No COALESCE: with both columns excluded whenever either is
|
||||
// NULL, CONCAT here never receives a NULL argument for path or
|
||||
// action - app_code cannot be NULL at all (see its own NOT NULL
|
||||
// tag) - so there is nothing left for COALESCE to guard against,
|
||||
// and leaving it out is deliberate rather than an oversight. A
|
||||
// future regression that removed the two IS NOT NULL conditions
|
||||
// above would fail loudly on MySQL (the same Scan error this
|
||||
// query used to produce) instead of quietly reporting a made-up
|
||||
// "duplicate" whose path and action both print as empty - the
|
||||
// failure this function exists to prevent in the first place.
|
||||
`SELECT CONCAT(app_code, '|', path, '|', action) FROM sys_api
|
||||
WHERE deleted_at = 0 AND path IS NOT NULL AND action IS NOT NULL
|
||||
GROUP BY app_code, path, action HAVING COUNT(*) > 1`,
|
||||
).Scan(&dupes).Error; err != nil {
|
||||
return fmt.Errorf("checking sys_api for duplicates: %w", err)
|
||||
}
|
||||
if len(dupes) > 0 {
|
||||
return fmt.Errorf(
|
||||
"sys_api already holds duplicate (app_code,path,action) %v; reconcile them before this migration can add its unique index",
|
||||
dupes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// postgresDB is defined in 1786700003000_soft_delete_marker_postgres_test.go.
|
||||
//
|
||||
// This file exists because refuseOnDuplicateApis's duplicate check is
|
||||
// spelled with CONCAT(), a function this migration's design assumed
|
||||
// PostgreSQL has carried since 9.1 but that nothing had run against a real
|
||||
// PostgreSQL server before this test - only against the pure-Go SQLite
|
||||
// driver, which happens to bundle a SQLite new enough to have grown its own
|
||||
// CONCAT() only recently. A dialect where that assumption were wrong would
|
||||
// otherwise only be discovered the first time an operator's install hit a
|
||||
// genuine sys_api duplicate on PostgreSQL in production.
|
||||
func TestSeedNaturalKeysRefusesDuplicateApisOnPostgres(t *testing.T) {
|
||||
db := postgresDB(t)
|
||||
t.Cleanup(func() { db.Migrator().DropTable(&oldSeedMenu{}, &oldSeedApi{}) })
|
||||
db.Migrator().DropTable(&oldSeedMenu{}, &oldSeedApi{})
|
||||
if err := db.AutoMigrate(&oldSeedMenu{}, &oldSeedApi{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.Create(&oldSeedApi{AppCode: "order", Path: "/api/v1/order", Action: "GET"}).Error; err != nil {
|
||||
t.Fatalf("seed duplicate %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
err := seedNaturalKeys(db)
|
||||
if err == nil {
|
||||
t.Fatal("PostgreSQL accepted sys_api rows that already hold a duplicate (app_code, path, action)")
|
||||
}
|
||||
if !contains(err.Error(), "order") || !contains(err.Error(), "/api/v1/order") {
|
||||
t.Errorf("the error does not name the offending row: %v", err)
|
||||
}
|
||||
if db.Migrator().HasIndex(&oldSeedApi{}, "uk_sys_api_app_path_action_del") {
|
||||
t.Error("the unique index was built despite the migration refusing")
|
||||
}
|
||||
}
|
||||
|
||||
// GROUP BY treats two NULLs as equal for grouping; a UNIQUE INDEX treats
|
||||
// every NULL as distinct from every other value, including another NULL.
|
||||
// Both are standard SQL, not a SQLite/PostgreSQL/MySQL difference - this
|
||||
// file exists to confirm that on a real server rather than assume it, the
|
||||
// same reason TestSeedNaturalKeysRefusesDuplicateApisOnPostgres above
|
||||
// exists for CONCAT(). See TestSeedNaturalKeysDoesNotFlagWhatTheIndexWouldAccept
|
||||
// in the SQLite-backed test file for the full account of why this matters:
|
||||
// a naive duplicate check that does not exclude NULL path/action refuses
|
||||
// an install the unique index itself would accept without complaint.
|
||||
func TestSeedNaturalKeysDoesNotFlagWhatTheIndexWouldAcceptOnPostgres(t *testing.T) {
|
||||
db := postgresDB(t)
|
||||
t.Cleanup(func() { db.Migrator().DropTable(&oldSeedMenu{}, &oldSeedApi{}) })
|
||||
db.Migrator().DropTable(&oldSeedMenu{}, &oldSeedApi{})
|
||||
if err := db.AutoMigrate(&oldSeedMenu{}, &oldSeedApi{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_api (app_code, path, action, deleted_at) VALUES ('order', NULL, NULL, 0)",
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seed NULL row %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("seedNaturalKeys refused a library the unique index itself accepts on PostgreSQL: %v", err)
|
||||
}
|
||||
if !db.Migrator().HasIndex(&oldSeedApi{}, "uk_sys_api_app_path_action_del") {
|
||||
t.Error("the unique index was not built on PostgreSQL even though seedNaturalKeys reported success")
|
||||
}
|
||||
}
|
||||
|
||||
// The success path, on the same server: both columns and both unique
|
||||
// indexes have to actually build on PostgreSQL, not merely fail to error
|
||||
// out on SQLite. Mirrors TestSeedNaturalKeysIsRepeatable's SQLite coverage.
|
||||
func TestSeedNaturalKeysBuildsOnPostgres(t *testing.T) {
|
||||
db := postgresDB(t)
|
||||
t.Cleanup(func() { db.Migrator().DropTable(&oldSeedMenu{}, &oldSeedApi{}) })
|
||||
db.Migrator().DropTable(&oldSeedMenu{}, &oldSeedApi{})
|
||||
if err := db.AutoMigrate(&oldSeedMenu{}, &oldSeedApi{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
if err := db.Create(&oldSeedApi{AppCode: "order", Path: "/api/v1/order", Action: "GET"}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("migrate %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !db.Migrator().HasColumn(&oldSeedMenu{}, "seed_code") {
|
||||
t.Error("sys_menu.seed_code was not added on PostgreSQL")
|
||||
}
|
||||
if !db.Migrator().HasIndex(&oldSeedMenu{}, "uk_sys_menu_app_seed_code_del") {
|
||||
t.Error("the sys_menu unique index was not built on PostgreSQL")
|
||||
}
|
||||
if !db.Migrator().HasIndex(&oldSeedApi{}, "uk_sys_api_app_path_action_del") {
|
||||
t.Error("the sys_api unique index was not built on PostgreSQL")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlserver"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
)
|
||||
|
||||
// sqlserverDSNEnv points these tests at a database. They skip without it, so
|
||||
// a developer with no SQL Server running still gets a green run.
|
||||
//
|
||||
// This file exists for the same reason the PostgreSQL one does, one driver
|
||||
// further along. The rest of the package runs on SQLite, where the defect it
|
||||
// covers cannot happen: SQLite, MySQL and PostgreSQL all treat two NULLs in a
|
||||
// unique index as different values, and SQL Server treats them as equal and
|
||||
// permits one. A suite that never pointed at SQL Server reported success for
|
||||
// a migration that could not be applied to any SQL Server database at all,
|
||||
// new or old.
|
||||
const sqlserverDSNEnv = "GO_ADMIN_TEST_SQLSERVER_DSN"
|
||||
|
||||
func sqlserverDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
dsn := os.Getenv(sqlserverDSNEnv)
|
||||
if dsn == "" {
|
||||
// Skipping locally is the point; skipping in CI is the failure this
|
||||
// file exists to prevent.
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Fatalf("%s is not set while CI is: the SQL Server migration tests must not skip here", sqlserverDSNEnv)
|
||||
}
|
||||
t.Skipf("%s is not set; skipping the SQL Server migration tests", sqlserverDSNEnv)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlserver.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("connecting to %s: %v", sqlserverDSNEnv, err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// freshSQLServerTables drops and rebuilds the two tables this migration
|
||||
// touches, so a rerun does not inherit the previous run's index.
|
||||
func freshSQLServerTables(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
for _, m := range []any{&adminmodels.SysMenu{}, &adminmodels.SysApi{}} {
|
||||
if db.Migrator().HasTable(m) {
|
||||
if err := db.Migrator().DropTable(m); err != nil {
|
||||
t.Fatalf("dropping: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := db.AutoMigrate(&adminmodels.SysMenu{}, &adminmodels.SysApi{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The migration completes on SQL Server.
|
||||
//
|
||||
// It did not. Five menus with no seed_code is what 1786700001000 leaves on
|
||||
// every database, and the unfiltered index rejects the second of them:
|
||||
//
|
||||
// Msg 1505 ... duplicate key ... The duplicate key value is (, <NULL>, 0).
|
||||
func TestSeedNaturalKeysOnSQLServer(t *testing.T) {
|
||||
db := sqlserverDB(t)
|
||||
freshSQLServerTables(t, db)
|
||||
|
||||
// Three rows in the state 1786700006000 leaves behind: an app_code that
|
||||
// defaulted to empty, no seed_code, and live.
|
||||
for _, name := range []string{"one", "two", "three"} {
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_menu (menu_name, app_code, deleted_at) VALUES (?, '', 0)", name,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seeding %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
// sys_api's key has two nullable columns and either one is enough to
|
||||
// collide, so both shapes are here. Two rows missing both, and two more
|
||||
// that have a path and no action: a filter naming only path would let
|
||||
// that second pair back into the index, where their equal NULLs collide.
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.Exec("INSERT INTO sys_api (app_code, deleted_at) VALUES ('', 0)").Error; err != nil {
|
||||
t.Fatalf("seeding sys_api: %v", err)
|
||||
}
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_api (app_code, path, deleted_at) VALUES ('', '/api/v1/half', 0)",
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seeding a sys_api row with no action: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("seedNaturalKeys on SQL Server: %v", err)
|
||||
}
|
||||
for _, name := range []string{"uk_sys_menu_app_seed_code_del", "uk_sys_api_app_path_action_del"} {
|
||||
var model any = &adminmodels.SysMenu{}
|
||||
if name == "uk_sys_api_app_path_action_del" {
|
||||
model = &adminmodels.SysApi{}
|
||||
}
|
||||
if !db.Migrator().HasIndex(model, name) {
|
||||
t.Errorf("%s was not created", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Rows that do carry a seed code still cannot collide - the filter takes
|
||||
// the ones with no value out of the index, it does not turn the index off.
|
||||
code := "dir"
|
||||
first := adminmodels.SysMenu{MenuName: "d1", AppCode: "order", SeedCode: &code}
|
||||
if err := db.Create(&first).Error; err != nil {
|
||||
t.Fatalf("first seeded menu: %v", err)
|
||||
}
|
||||
second := adminmodels.SysMenu{MenuName: "d2", AppCode: "order", SeedCode: &code}
|
||||
if err := db.Create(&second).Error; err == nil {
|
||||
t.Error("a duplicate (app_code, seed_code) was accepted; the filtered index is not enforcing anything")
|
||||
}
|
||||
// A different app may reuse the same seed code, which is why the key is
|
||||
// composite in the first place.
|
||||
other := adminmodels.SysMenu{MenuName: "d3", AppCode: "crm", SeedCode: &code}
|
||||
if err := db.Create(&other).Error; err != nil {
|
||||
t.Errorf("another app could not reuse the seed code: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The control. Without the filter the statement fails on this engine, so the
|
||||
// test above is passing because of the fix rather than because SQL Server
|
||||
// turned out not to mind.
|
||||
func TestSQLServerRejectsTheUnfilteredIndex(t *testing.T) {
|
||||
db := sqlserverDB(t)
|
||||
freshSQLServerTables(t, db)
|
||||
|
||||
for _, name := range []string{"one", "two"} {
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_menu (menu_name, app_code, deleted_at) VALUES (?, '', 0)", name,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seeding %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
err := db.Exec(uniqueIndexOverNullable("postgres",
|
||||
"uk_unfiltered_probe", "sys_menu", "app_code, seed_code, deleted_at", "seed_code")).Error
|
||||
if err == nil {
|
||||
t.Fatal("SQL Server accepted two NULLs in a unique index; the filter this migration adds is not needed")
|
||||
}
|
||||
t.Logf("as expected: %v", err)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// oldSeedMenu/oldSeedApi are the shape of sys_menu/sys_api immediately
|
||||
// before this migration: post-1786700003000 (deleted_at is the NOT NULL
|
||||
// millisecond marker) and post-1786700006000 (app_code exists), but before
|
||||
// seed_code or either unique index. They stand in for the real runtime
|
||||
// models, which by the time this file is read already carry the columns
|
||||
// this migration adds - the same relationship oldUser bears to sys_user in
|
||||
// 1786700003000_soft_delete_marker_test.go.
|
||||
type oldSeedMenu struct {
|
||||
MenuId int `gorm:"column:menu_id;primaryKey;autoIncrement"`
|
||||
AppCode string `gorm:"column:app_code;type:varchar(64);not null;default:''"`
|
||||
DeletedAt int64 `gorm:"column:deleted_at;not null;default:0"`
|
||||
}
|
||||
|
||||
func (oldSeedMenu) TableName() string { return "sys_menu" }
|
||||
|
||||
type oldSeedApi struct {
|
||||
Id int `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
AppCode string `gorm:"column:app_code;type:varchar(64);not null;default:''"`
|
||||
Path string `gorm:"column:path;type:varchar(128)"`
|
||||
Action string `gorm:"column:action;type:varchar(16)"`
|
||||
DeletedAt int64 `gorm:"column:deleted_at;not null;default:0"`
|
||||
}
|
||||
|
||||
func (oldSeedApi) TableName() string { return "sys_api" }
|
||||
|
||||
func openSeedNaturalKeysDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&oldSeedMenu{}, &oldSeedApi{}, &common.Migration{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// The host's own hand-placed menus, and every app-seeded row written
|
||||
// before this column existed, have no seed_code at all - an unbounded
|
||||
// number of those must coexist under the same app_code without tripping
|
||||
// the new unique index (design doc §1.6: "NULL never treated as equal to
|
||||
// NULL").
|
||||
func TestSeedNaturalKeysToleratesManyPreExistingMenusWithNoSeedCode(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := db.Create(&oldSeedMenu{AppCode: ""}).Error; err != nil {
|
||||
t.Fatalf("seed pre-existing menu %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
if !db.Migrator().HasColumn(&adminmodels.SysMenu{}, "SeedCode") {
|
||||
t.Fatal("sys_menu.seed_code was not added")
|
||||
}
|
||||
}
|
||||
|
||||
// The point of adding seed_code at all: a second row with the same
|
||||
// (app_code, seed_code) while both are live is what seedMenuTree's
|
||||
// idempotency check depends on the database to reject if the Go-level
|
||||
// check above it is ever bypassed or raced.
|
||||
func TestSeedNaturalKeysMenuUniqueIndexBindsLiveRowsOnly(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_menu (app_code, seed_code, deleted_at) VALUES ('order', 'dir', 0)",
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
t.Run("a second live row with the same natural key is rejected", func(t *testing.T) {
|
||||
err := db.Exec(
|
||||
"INSERT INTO sys_menu (app_code, seed_code, deleted_at) VALUES ('order', 'dir', 0)",
|
||||
).Error
|
||||
if err == nil {
|
||||
t.Fatal("a duplicate (app_code, seed_code) was accepted while both rows were live")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the key is free again once the row is soft-deleted", func(t *testing.T) {
|
||||
if err := db.Exec("UPDATE sys_menu SET deleted_at = ? WHERE seed_code = 'dir'", time.Now().UnixMilli()).Error; err != nil {
|
||||
t.Fatalf("soft-delete: %v", err)
|
||||
}
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_menu (app_code, seed_code, deleted_at) VALUES ('order', 'dir', 0)",
|
||||
).Error; err != nil {
|
||||
t.Errorf("the key stayed taken after its row was soft-deleted: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The demo site has already proven sys_api can hold historical duplicates;
|
||||
// the migration has to name them and refuse, not let CREATE UNIQUE INDEX
|
||||
// fail on an operator with no idea which rows to reconcile.
|
||||
func TestSeedNaturalKeysRefusesDuplicateApis(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.Create(&oldSeedApi{AppCode: "order", Path: "/api/v1/order", Action: "GET"}).Error; err != nil {
|
||||
t.Fatalf("seed duplicate %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
err := seedNaturalKeys(db)
|
||||
if err == nil {
|
||||
t.Fatal("the migration accepted sys_api rows that already hold a duplicate (app_code, path, action)")
|
||||
}
|
||||
if !contains(err.Error(), "order") || !contains(err.Error(), "/api/v1/order") {
|
||||
t.Errorf("the error does not name the offending row: %v", err)
|
||||
}
|
||||
if db.Migrator().HasIndex(&adminmodels.SysApi{}, "uk_sys_api_app_path_action_del") {
|
||||
t.Error("the unique index was built despite the migration refusing")
|
||||
}
|
||||
// sys_menu's column and index are independent of sys_api's outcome and
|
||||
// should already be in place - a partial failure here still leaves a
|
||||
// record of what succeeded, same as any other non-transactional DDL
|
||||
// migration in this package.
|
||||
if !db.Migrator().HasColumn(&adminmodels.SysMenu{}, "SeedCode") {
|
||||
t.Error("sys_menu.seed_code was not added even though only the sys_api step failed")
|
||||
}
|
||||
}
|
||||
|
||||
// Only live rows count towards the duplicate check: a row a prior,
|
||||
// unrelated soft-delete already retired does not block the index it will
|
||||
// never occupy a slot in.
|
||||
func TestSeedNaturalKeysIgnoresSoftDeletedApiDuplicates(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
if err := db.Create(&oldSeedApi{AppCode: "order", Path: "/api/v1/order", Action: "GET"}).Error; err != nil {
|
||||
t.Fatalf("seed live row: %v", err)
|
||||
}
|
||||
if err := db.Create(&oldSeedApi{AppCode: "order", Path: "/api/v1/order", Action: "GET", DeletedAt: time.Now().UnixMilli()}).Error; err != nil {
|
||||
t.Fatalf("seed soft-deleted row: %v", err)
|
||||
}
|
||||
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if !db.Migrator().HasIndex(&adminmodels.SysApi{}, "uk_sys_api_app_path_action_del") {
|
||||
t.Error("the unique index was not built")
|
||||
}
|
||||
}
|
||||
|
||||
// The point of the sys_api index, mirroring
|
||||
// TestSeedNaturalKeysMenuUniqueIndexBindsLiveRowsOnly above: a second live
|
||||
// row is rejected, and the key is free again once the row is
|
||||
// soft-deleted.
|
||||
func TestSeedNaturalKeysApiUniqueIndexBindsLiveRowsOnly(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
if err := db.Create(&oldSeedApi{AppCode: "order", Path: "/api/v1/order", Action: "GET"}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
t.Run("a second live row with the same natural key is rejected", func(t *testing.T) {
|
||||
err := db.Exec(
|
||||
"INSERT INTO sys_api (app_code, path, action, deleted_at) VALUES ('order', '/api/v1/order', 'GET', 0)",
|
||||
).Error
|
||||
if err == nil {
|
||||
t.Fatal("a duplicate (app_code, path, action) was accepted while both rows were live")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the key is free again once the row is soft-deleted", func(t *testing.T) {
|
||||
if err := db.Exec(
|
||||
"UPDATE sys_api SET deleted_at = ? WHERE path = '/api/v1/order'", time.Now().UnixMilli(),
|
||||
).Error; err != nil {
|
||||
t.Fatalf("soft-delete: %v", err)
|
||||
}
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_api (app_code, path, action, deleted_at) VALUES ('order', '/api/v1/order', 'GET', 0)",
|
||||
).Error; err != nil {
|
||||
t.Errorf("the key stayed taken after its row was soft-deleted: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Running it twice must be safe: DDL does not roll back on MySQL, so an
|
||||
// operator whose first attempt failed partway through (say, sys_menu's step
|
||||
// succeeded and sys_api's refused) has nothing to do but run the whole
|
||||
// migration again once the duplicates are reconciled.
|
||||
func TestSeedNaturalKeysIsRepeatable(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
if err := db.Create(&oldSeedApi{AppCode: "order", Path: "/api/v1/order", Action: "GET"}).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("migrate %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The wrapper's contract with Migrate.run(): the version is only recorded
|
||||
// once the whole thing - both columns, both indexes - succeeded.
|
||||
func TestSeedNaturalKeysWrapperRecordsTheVersion(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
if err := _1786700008000SeedNaturalKeys(db, "1786700008000"); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
var applied common.Migration
|
||||
if err := db.Where("version = ?", "1786700008000").First(&applied).Error; err != nil {
|
||||
t.Fatalf("sys_migration was not recorded: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GROUP BY treats two NULLs as equal for grouping purposes; a UNIQUE INDEX
|
||||
// treats every NULL as distinct from every other value, including another
|
||||
// NULL - both are standard SQL semantics, not a quirk of one dialect (see
|
||||
// the postgres-only test file next to this one for the same check against
|
||||
// a real server). A duplicate check that groups on the raw columns without
|
||||
// accounting for that difference refuses an install the index itself would
|
||||
// accept without complaint, on data there is nothing to "reconcile" -
|
||||
// worse than the index simply failing to build, because it stops a library
|
||||
// that has nothing wrong with it.
|
||||
//
|
||||
// sys_api.path/action carry no NOT NULL constraint - see the design doc's
|
||||
// note on this migration for why that stays true in this batch, changing
|
||||
// it is an independent, backward-incompatible migration of its own - so
|
||||
// this state is reachable in a real database even though seedApis's own
|
||||
// Create call, which always writes the Go zero value "" rather than NULL,
|
||||
// never produces it itself. Inserted via raw SQL for exactly that reason:
|
||||
// models.SysApi's Path/Action are plain (non-pointer) Go strings, which
|
||||
// cannot represent NULL through a normal Create call.
|
||||
func TestSeedNaturalKeysDoesNotFlagWhatTheIndexWouldAccept(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.Exec(
|
||||
"INSERT INTO sys_api (app_code, path, action, deleted_at) VALUES ('order', NULL, NULL, 0)",
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seed NULL row %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("seedNaturalKeys refused a library the unique index itself accepts: %v", err)
|
||||
}
|
||||
if !db.Migrator().HasIndex(&adminmodels.SysApi{}, "uk_sys_api_app_path_action_del") {
|
||||
t.Error("the unique index was not built even though seedNaturalKeys reported success")
|
||||
}
|
||||
}
|
||||
|
||||
// The case above has both path and action NULL on every row, which both
|
||||
// of the query's two NULL-exclusion conditions independently catch - it
|
||||
// cannot tell "only path IS NOT NULL is doing anything here" apart from
|
||||
// "both conditions are doing something". A row missing only one of the
|
||||
// two is exactly as real (an api registered with a path but no method,
|
||||
// or vice versa) and exercises only one condition at a time: two rows
|
||||
// sharing a real path but both NULL in action, or two rows sharing a real
|
||||
// action but both NULL in path. GROUP BY treats each pair's shared NULL
|
||||
// the same way it treats a shared (NULL, NULL) - as equal - and the
|
||||
// unique index accepts both pairs for the same reason it accepts the
|
||||
// (NULL, NULL) case, so neither belongs in the count either.
|
||||
func TestSeedNaturalKeysDoesNotFlagPartiallyNullRows(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
insert string // two rows, sharing a value in exactly one of path/action
|
||||
}{
|
||||
{
|
||||
name: "path is null, action repeats",
|
||||
insert: "INSERT INTO sys_api (app_code, path, action, deleted_at) VALUES ('order', NULL, 'GET', 0)",
|
||||
},
|
||||
{
|
||||
name: "action is null, path repeats",
|
||||
insert: "INSERT INTO sys_api (app_code, path, action, deleted_at) VALUES ('order', '/api/v1/order', NULL, 0)",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := openSeedNaturalKeysDB(t)
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.Exec(tc.insert).Error; err != nil {
|
||||
t.Fatalf("seed row %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := seedNaturalKeys(db); err != nil {
|
||||
t.Fatalf("seedNaturalKeys refused a library the unique index itself accepts: %v", err)
|
||||
}
|
||||
if !db.Migrator().HasIndex(&adminmodels.SysApi{}, "uk_sys_api_app_path_action_del") {
|
||||
t.Error("the unique index was not built even though seedNaturalKeys reported success")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The index has to mean the same thing on every driver this repository
|
||||
// registers, and the drivers do not agree about NULL.
|
||||
//
|
||||
// MySQL, PostgreSQL and SQLite treat two NULLs as different values, so any
|
||||
// number of rows missing one of these columns coexist under the index. SQL
|
||||
// Server treats them as equal and permits exactly one, so the unfiltered
|
||||
// statement fails there on any database with two rows lacking a seed_code -
|
||||
// which is every database, a brand-new one included, because 1786700001000
|
||||
// seeds five menus and none of them carries one.
|
||||
func TestUniqueIndexOverNullableFiltersOnlyWhereItHasTo(t *testing.T) {
|
||||
const plain = "CREATE UNIQUE INDEX uk ON sys_menu (app_code, seed_code, deleted_at)"
|
||||
|
||||
for _, dialect := range []string{"mysql", "postgres", "sqlite"} {
|
||||
got := uniqueIndexOverNullable(dialect, "uk", "sys_menu", "app_code, seed_code, deleted_at", "seed_code")
|
||||
if got != plain {
|
||||
t.Errorf("%s: %q\n want %q", dialect, got, plain)
|
||||
}
|
||||
}
|
||||
|
||||
got := uniqueIndexOverNullable("sqlserver", "uk", "sys_menu", "app_code, seed_code, deleted_at", "seed_code")
|
||||
want := plain + " WHERE seed_code IS NOT NULL"
|
||||
if got != want {
|
||||
t.Errorf("sqlserver: %q\n want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// sys_api's key has two nullable columns, and either one being NULL is enough
|
||||
// to collide on SQL Server.
|
||||
func TestUniqueIndexOverNullableCoversEveryNullableColumn(t *testing.T) {
|
||||
got := uniqueIndexOverNullable("sqlserver", "uk", "sys_api",
|
||||
"app_code, path, action, deleted_at", "path", "action")
|
||||
if !strings.HasSuffix(got, " WHERE path IS NOT NULL AND action IS NOT NULL") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A key with nothing nullable in it needs no filter anywhere, or SQL Server
|
||||
// would get a WHERE clause naming no column.
|
||||
func TestUniqueIndexOverNullableWithoutNullableColumns(t *testing.T) {
|
||||
got := uniqueIndexOverNullable("sqlserver", "uk", "sys_menu", "app_code, deleted_at")
|
||||
if strings.Contains(got, "WHERE") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
jobmodels "go-admin/app/jobs/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Create sys_job_lease and seed the one row the scheduler competes for
|
||||
// (issue #915).
|
||||
//
|
||||
// The row is seeded here rather than created on demand at startup. Two
|
||||
// instances starting together would otherwise race to insert the very row
|
||||
// they are each trying to claim, and the loser would have to tell a
|
||||
// duplicate-key error apart from a real one in whichever driver it is
|
||||
// running against. Seeding it makes the runtime path two UPDATE statements
|
||||
// and nothing else.
|
||||
//
|
||||
// It is seeded free - no owner, and an expiry far enough in the past that
|
||||
// the first instance to ask takes it - so that installing this migration
|
||||
// does not leave the scheduler waiting out a TTL that nobody is holding.
|
||||
//
|
||||
// Ordered after 1786700003000 (the soft-delete conversion), so importing
|
||||
// cmd/migrate/migration/models is banned here - see
|
||||
// schema_coverage_test.go's TestPostConversionMigrationsAvoidFrozenSeedModels.
|
||||
// sys_job_lease is AutoMigrate'd from its runtime model under
|
||||
// app/jobs/models directly, and it is absent from 1786700003000's frozen
|
||||
// softDeleteTables list because it embeds no common.ModelTime: a lease that
|
||||
// could be soft-deleted would be a row that both does and does not hold the
|
||||
// scheduler.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700009000JobSchedulerLease)
|
||||
}
|
||||
|
||||
func _1786700009000JobSchedulerLease(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Migrator().AutoMigrate(new(jobmodels.SysJobLease)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Seeded free: no owner, and an expiry of 0 - before every clock
|
||||
// reading there will ever be - so the first instance to ask takes
|
||||
// it rather than waiting out a TTL nobody is holding.
|
||||
lease := jobmodels.SysJobLease{
|
||||
Name: jobmodels.SchedulerLeaseName,
|
||||
Owner: "",
|
||||
AcquiredAtMs: 0,
|
||||
ExpiresAtMs: 0,
|
||||
}
|
||||
if err := tx.Create(&lease).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
jobmodels "go-admin/app/jobs/models"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
func openJobLeaseDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// The runtime has no insert path - two instances starting together would
|
||||
// race to create the row they are both trying to claim - so the row has to
|
||||
// exist when the migration finishes or nothing ever schedules anything.
|
||||
func TestTheSchedulerLeaseMigrationLeavesExactlyOneFreeRow(t *testing.T) {
|
||||
db := openJobLeaseDB(t)
|
||||
|
||||
if err := _1786700009000JobSchedulerLease(db, "1786700009000"); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
if !db.Migrator().HasTable(&jobmodels.SysJobLease{}) {
|
||||
t.Fatal("sys_job_lease was not created")
|
||||
}
|
||||
|
||||
var rows []jobmodels.SysJobLease
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
t.Fatalf("reading sys_job_lease: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("sys_job_lease holds %d rows, want exactly 1", len(rows))
|
||||
}
|
||||
|
||||
row := rows[0]
|
||||
if row.Name != jobmodels.SchedulerLeaseName {
|
||||
t.Errorf("the seeded row is named %q, want %q; acquire looks the row up by this name and would find nothing",
|
||||
row.Name, jobmodels.SchedulerLeaseName)
|
||||
}
|
||||
if row.Owner != "" {
|
||||
t.Errorf("the seeded lease is owned by %q; a fresh install would wait out a TTL held by nobody", row.Owner)
|
||||
}
|
||||
// Zero, not "now": the take is `expires_at_ms <= now`, so a seeded
|
||||
// expiry in the future is a scheduler that does not start until it
|
||||
// passes.
|
||||
if row.ExpiresAtMs != 0 {
|
||||
t.Errorf("the seeded lease expires at %d, want 0", row.ExpiresAtMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSchedulerLeaseMigrationRecordsItsVersion(t *testing.T) {
|
||||
db := openJobLeaseDB(t)
|
||||
|
||||
if err := _1786700009000JobSchedulerLease(db, "1786700009000"); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
var got common.Migration
|
||||
if err := db.Where("version = ?", "1786700009000").First(&got).Error; err != nil {
|
||||
t.Fatalf("the migration did not record its version, so it would run again on every start: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/other/models/tools"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
// Add sys_columns.col_width and sys_columns.default_value for PRD 010 F1/F2
|
||||
// (代码生成器前端模板迁移 Vue 3).
|
||||
//
|
||||
// col_width backs R2's column-width inference fallback and default_value
|
||||
// backs R1/A6's "unconfigured rows still generate a usable page" guarantee -
|
||||
// see docs-prd/010-代码生成器前端模板迁移Vue3/数据库变更.md §1.1 for why both
|
||||
// defaults are sentinels (0 / "") rather than NULL: a non-pointer Go int/
|
||||
// string field can never read NULL back out, and NULL would give
|
||||
// "unconfigured" two representations instead of one.
|
||||
//
|
||||
// Ordered after 1786700003000, so this reads tools.SysColumns (the runtime
|
||||
// model sys_columns's Update/GetPage/GetSysTablesInfo actually query through)
|
||||
// rather than cmd/migrate/migration/models, matching every migration in this
|
||||
// directory since sys_columns was converted - see
|
||||
// 1786700004000_generator_tables_marker.go and schema_coverage_test.go's
|
||||
// TestPostConversionMigrationsAvoidFrozenSeedModels.
|
||||
//
|
||||
// Hard prerequisite: tools.SysColumns must already declare ColWidth and
|
||||
// DefaultValue (with the gorm tags in the doc above) by the time this file
|
||||
// is compiled - AddColumn reads the column definition off the struct's own
|
||||
// tag, not off anything in this file. Landing this migration without that
|
||||
// model change first makes HasColumn/AddColumn silently do nothing (the
|
||||
// field lookup fails and AddColumn returns an error naming the missing
|
||||
// field), which fails loudly rather than silently - see the "no such field"
|
||||
// error - so this is caught at migrate time, not left for a report later.
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700010000GenColumnLayoutFields)
|
||||
}
|
||||
|
||||
func _1786700010000GenColumnLayoutFields(db *gorm.DB, version string) error {
|
||||
m := db.Migrator()
|
||||
if !m.HasColumn(&tools.SysColumns{}, "ColWidth") {
|
||||
if err := m.AddColumn(&tools.SysColumns{}, "ColWidth"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !m.HasColumn(&tools.SysColumns{}, "DefaultValue") {
|
||||
if err := m.AddColumn(&tools.SysColumns{}, "DefaultValue"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
}
|
||||
+128
-9
@@ -3,6 +3,7 @@ package migrate
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/config/source/file"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/app"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/config"
|
||||
@@ -47,6 +49,31 @@ var (
|
||||
runStatus()
|
||||
},
|
||||
}
|
||||
// Under migrate rather than under the existing `app` command, which
|
||||
// already means "generate the skeleton of a new app" - a directory that
|
||||
// does not exist yet, not an application already compiled into this
|
||||
// binary. Installing an application is running its migrations, which is
|
||||
// what this command is; --app, --domain and resolveDB are all already
|
||||
// here, including the guard that refuses a mistyped code instead of
|
||||
// reporting a successful no-op.
|
||||
installCmd = &cobra.Command{
|
||||
Use: "install <code>",
|
||||
Short: "Install one application: run its migrations and record it in sys_app",
|
||||
Example: "go-admin migrate install order -c config/settings.yml",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runInstall(args[0])
|
||||
},
|
||||
}
|
||||
uninstallCmd = &cobra.Command{
|
||||
Use: "uninstall <code>",
|
||||
Short: "Remove one application's menus, apis and permission grants; its own tables are left alone",
|
||||
Example: "go-admin migrate uninstall order -c config/settings.yml",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runUninstall(args[0])
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// fixme 在您看不见代码的时候运行迁移,我觉得是不安全的,所以编译后最好不要去执行迁移
|
||||
@@ -64,6 +91,8 @@ func init() {
|
||||
StartCmd.Flags().BoolVar(&dryRun, "dry-run", false, "list what would be applied, in order, and write nothing")
|
||||
|
||||
StartCmd.AddCommand(statusCmd)
|
||||
StartCmd.AddCommand(installCmd)
|
||||
StartCmd.AddCommand(uninstallCmd)
|
||||
}
|
||||
|
||||
func run() {
|
||||
@@ -162,11 +191,9 @@ func migrateModel() error {
|
||||
}
|
||||
migration.Migrate.SetDb(db.Debug())
|
||||
if appCode != "" {
|
||||
migration.Migrate.MigrateApp(appCode)
|
||||
return nil
|
||||
return migration.Migrate.MigrateApp(appCode)
|
||||
}
|
||||
migration.Migrate.Migrate()
|
||||
return nil
|
||||
return migration.Migrate.Migrate()
|
||||
}
|
||||
|
||||
func initDB() {
|
||||
@@ -197,13 +224,40 @@ func initDB() {
|
||||
|
||||
//4. 数据库迁移
|
||||
fmt.Println("数据库迁移开始")
|
||||
if err := migrateModel(); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
exitOnError(os.Stderr, migrateModel())
|
||||
fmt.Println(`数据库基础数据初始化成功`)
|
||||
}
|
||||
|
||||
// exitOnError ends the command non-zero when the migration did not go through.
|
||||
//
|
||||
// A caller that migrates before starting a server decides whether to go ahead
|
||||
// on the exit code alone - the deploy workflow does exactly that. Every path
|
||||
// out of migrateModel used to return without one: an unreachable tenant
|
||||
// database or a failed AutoMigrate printed a line and exited 0, so a
|
||||
// deployment carried on onto a schema that had not been brought forward. A
|
||||
// failing migration function was the only one reported, and only because it
|
||||
// ended the process from inside the migration engine - which is the call this
|
||||
// batch moved out here, so without this the last reported failure would have
|
||||
// stopped being reported too.
|
||||
//
|
||||
// Split from the exit itself, the way appRegistrationError is split from
|
||||
// exitUnlessAppRegistered, so what it decides can be tested without a
|
||||
// subprocess. osExit is a variable for the same reason.
|
||||
func exitOnError(w io.Writer, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, err)
|
||||
osExit(1)
|
||||
}
|
||||
|
||||
// osExit is a variable so a test can watch the decision without ending the
|
||||
// test binary; origExit is what it is put back to.
|
||||
var (
|
||||
osExit = os.Exit
|
||||
origExit = os.Exit
|
||||
)
|
||||
|
||||
func runStatus() {
|
||||
config.Setup(
|
||||
file.NewSource(file.WithPath(configYml)),
|
||||
@@ -222,13 +276,78 @@ func runStatus() {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err = printStatus(os.Stdout, entries, appCode); err != nil {
|
||||
// Which applications exist is a different question from which
|
||||
// migrations ran, and an install that stopped partway is only
|
||||
// visible in the answer to the first.
|
||||
apps, err := loadApps(db)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err = printStatus(os.Stdout, entries, apps, appCode); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func runInstall(code string) {
|
||||
config.Setup(
|
||||
file.NewSource(file.WithPath(configYml)),
|
||||
func() {
|
||||
database.Setup()
|
||||
db, err := resolveDB()
|
||||
if err != nil {
|
||||
exitOnError(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
registered := app.Snapshot()
|
||||
m, err := manifestFor(registered, code)
|
||||
if err != nil {
|
||||
exitOnError(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
// Over every registered manifest, not just this one's closure: a
|
||||
// cycle between two other applications is still an authoring
|
||||
// mistake, and the day somebody installs into it is the worse
|
||||
// time to find out.
|
||||
if err := refuseOnDependencyCycle(registered); err != nil {
|
||||
exitOnError(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
rep, err := install(db, migration.Migrate, m)
|
||||
if err != nil {
|
||||
exitOnError(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
reportInstall(os.Stdout, rep)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func runUninstall(code string) {
|
||||
config.Setup(
|
||||
file.NewSource(file.WithPath(configYml)),
|
||||
func() {
|
||||
database.Setup()
|
||||
db, err := resolveDB()
|
||||
if err != nil {
|
||||
exitOnError(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
// No manifest lookup. An application whose code has already been
|
||||
// taken out of the binary registers nothing, and that is exactly
|
||||
// when somebody needs to clear its rows out of the database.
|
||||
rep, err := uninstall(db, code)
|
||||
if err != nil {
|
||||
exitOnError(os.Stderr, err)
|
||||
return
|
||||
}
|
||||
reportUninstall(os.Stdout, rep)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func genFile() error {
|
||||
t1, err := template.ParseFiles("template/migrate.template")
|
||||
if err != nil {
|
||||
|
||||
+79
-4
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
)
|
||||
|
||||
@@ -15,11 +16,24 @@ const applyTimeLayout = "2006-01-02 15:04:05"
|
||||
// printStatus lists every migration this binary knows about together with every
|
||||
// row already in sys_migration, grouped by app.
|
||||
//
|
||||
// apps is what sys_app says about each of them, keyed by app code, and it
|
||||
// answers a different question from the migration rows: an install that
|
||||
// stopped partway leaves migrations that all read "applied" and a row that
|
||||
// says the install never finished. A nil map is a database from before
|
||||
// sys_app existed, and the listing is then exactly what it was.
|
||||
//
|
||||
// The app list is the union of the two. Reading it from sys_app alone would
|
||||
// drop an application whose migrations ran under plain `migrate` and which
|
||||
// therefore has no row; reading it from the migration rows alone drops one
|
||||
// whose code has been taken out of the binary, which is when somebody most
|
||||
// wants to see it named.
|
||||
//
|
||||
// filter is an app code as typed on the command line; empty means every app.
|
||||
func printStatus(w io.Writer, entries []migration.StatusEntry, filter string) error {
|
||||
func printStatus(w io.Writer, entries []migration.StatusEntry, apps map[string]adminmodels.SysApp, filter string) error {
|
||||
entries = filterByApp(entries, filter)
|
||||
apps = filterAppsByApp(apps, filter)
|
||||
|
||||
groups, order := groupByApp(entries)
|
||||
groups, order := groupByApp(entries, apps)
|
||||
if len(order) == 0 {
|
||||
_, err := fmt.Fprintln(w, "no migrations registered and none recorded")
|
||||
return err
|
||||
@@ -34,7 +48,14 @@ func printStatus(w io.Writer, entries []migration.StatusEntry, filter string) er
|
||||
if i > 0 {
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
fmt.Fprintf(w, "[%s]\n", app)
|
||||
fmt.Fprintf(w, "[%s]%s\n", app, appSummary(apps, app))
|
||||
if len(groups[app]) == 0 {
|
||||
// A row in sys_app and not one migration, recorded or
|
||||
// registered. Its code is out of this binary and its migration
|
||||
// records have been removed, and the row is all that is left to
|
||||
// say it was ever here.
|
||||
fmt.Fprintln(w, " no migrations registered in this binary and none recorded")
|
||||
}
|
||||
for _, e := range groups[app] {
|
||||
state := "pending"
|
||||
switch {
|
||||
@@ -133,12 +154,21 @@ func filterByApp(entries []migration.StatusEntry, filter string) []migration.Sta
|
||||
// order to print them in: the framework first, then apps alphabetically. That
|
||||
// is also the order a full run executes them in, because version strings sort
|
||||
// as ASCII and the framework's are bare digits.
|
||||
func groupByApp(entries []migration.StatusEntry) (map[string][]migration.StatusEntry, []string) {
|
||||
func groupByApp(entries []migration.StatusEntry, apps map[string]adminmodels.SysApp) (map[string][]migration.StatusEntry, []string) {
|
||||
groups := make(map[string][]migration.StatusEntry)
|
||||
for _, e := range entries {
|
||||
app := migration.DisplayAppCode(e.AppCode)
|
||||
groups[app] = append(groups[app], e)
|
||||
}
|
||||
// An application sys_app knows about and no migration mentions still gets
|
||||
// a group, empty. That is the one case the migration rows cannot report
|
||||
// at all.
|
||||
for code := range apps {
|
||||
app := migration.DisplayAppCode(code)
|
||||
if _, ok := groups[app]; !ok {
|
||||
groups[app] = nil
|
||||
}
|
||||
}
|
||||
order := make([]string, 0, len(groups))
|
||||
for app := range groups {
|
||||
order = append(order, app)
|
||||
@@ -152,6 +182,51 @@ func groupByApp(entries []migration.StatusEntry) (map[string][]migration.StatusE
|
||||
return groups, order
|
||||
}
|
||||
|
||||
// appSummary is what sys_app says about one application, as a suffix for its
|
||||
// group header. Empty when there is no row: an application whose migrations
|
||||
// ran under plain `migrate` has none, and neither does any application on a
|
||||
// database from before sys_app existed.
|
||||
func appSummary(apps map[string]adminmodels.SysApp, display string) string {
|
||||
// AppFilter, not NormalizeAppCode: this takes a display code back to the
|
||||
// stored one, and only AppFilter is that inverse. It maps the framework
|
||||
// to the empty string, which loadApps never files a row under, so the
|
||||
// framework needs no branch of its own here.
|
||||
row, ok := apps[migration.AppFilter(display)]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch row.Status {
|
||||
case adminmodels.AppInstalled:
|
||||
return fmt.Sprintf(" %s installed", row.Version)
|
||||
case adminmodels.AppFailed:
|
||||
if row.FailedVersion != "" {
|
||||
return fmt.Sprintf(" %s failed at %s", row.Version, row.FailedVersion)
|
||||
}
|
||||
return fmt.Sprintf(" %s failed", row.Version)
|
||||
case adminmodels.AppInstalling:
|
||||
// Not "installing" as in "right now": nothing holds this state while
|
||||
// it works. It is what is left when an attempt did not reach either
|
||||
// end, and running the install again is what clears it.
|
||||
return fmt.Sprintf(" %s did not finish installing", row.Version)
|
||||
default:
|
||||
return fmt.Sprintf(" %s status %d", row.Version, row.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// filterAppsByApp narrows the sys_app rows the same way filterByApp narrows
|
||||
// the migrations, so --app names one application in both halves of the report.
|
||||
func filterAppsByApp(apps map[string]adminmodels.SysApp, filter string) map[string]adminmodels.SysApp {
|
||||
if filter == "" {
|
||||
return apps
|
||||
}
|
||||
want := migration.AppFilter(filter)
|
||||
out := make(map[string]adminmodels.SysApp, 1)
|
||||
if row, ok := apps[want]; ok {
|
||||
out[want] = row
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatApplyTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
|
||||
+119
-5
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
)
|
||||
|
||||
@@ -29,7 +30,7 @@ func sampleEntries() []migration.StatusEntry {
|
||||
|
||||
func TestPrintStatusGroupsByApp(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := printStatus(&buf, sampleEntries(), ""); err != nil {
|
||||
if err := printStatus(&buf, sampleEntries(), nil, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.String()
|
||||
@@ -62,7 +63,7 @@ func TestPrintStatusMarksOrphanedRows(t *testing.T) {
|
||||
Version: "gone-1786800000000", AppCode: "gone", Applied: true, ApplyTime: at("2026-08-01 09:00:00"),
|
||||
})
|
||||
var buf bytes.Buffer
|
||||
if err := printStatus(&buf, entries, ""); err != nil {
|
||||
if err := printStatus(&buf, entries, nil, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.String()
|
||||
@@ -79,7 +80,7 @@ func TestPrintStatusMarksOrphanedRows(t *testing.T) {
|
||||
|
||||
func TestPrintStatusFiltersByApp(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := printStatus(&buf, sampleEntries(), "crm"); err != nil {
|
||||
if err := printStatus(&buf, sampleEntries(), nil, "crm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.String()
|
||||
@@ -94,7 +95,7 @@ func TestPrintStatusFiltersByApp(t *testing.T) {
|
||||
// status prints [core]; --app core has to mean the same thing.
|
||||
func TestPrintStatusAppCoreSelectsTheFramework(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := printStatus(&buf, sampleEntries(), migration.FrameworkAppCode); err != nil {
|
||||
if err := printStatus(&buf, sampleEntries(), nil, migration.FrameworkAppCode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.String()
|
||||
@@ -108,7 +109,7 @@ func TestPrintStatusAppCoreSelectsTheFramework(t *testing.T) {
|
||||
|
||||
func TestPrintStatusOnAnEmptyRegistry(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := printStatus(&buf, nil, ""); err != nil {
|
||||
if err := printStatus(&buf, nil, nil, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "no migrations registered and none recorded") {
|
||||
@@ -168,3 +169,116 @@ func TestPrintPendingFiltersByApp(t *testing.T) {
|
||||
t.Errorf("output = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func appRows(rows ...adminmodels.SysApp) map[string]adminmodels.SysApp {
|
||||
out := make(map[string]adminmodels.SysApp, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.AppCode] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The migration rows say every one of an application's migrations ran. Only
|
||||
// sys_app can say the install that ran them never finished.
|
||||
func TestPrintStatusShowsWhatSysAppSays(t *testing.T) {
|
||||
apps := appRows(
|
||||
adminmodels.SysApp{AppCode: "crm", Version: "1.2.0", Status: adminmodels.AppInstalled},
|
||||
)
|
||||
var buf bytes.Buffer
|
||||
if err := printStatus(&buf, sampleEntries(), apps, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "[crm] 1.2.0 installed") {
|
||||
t.Errorf("the header does not carry what sys_app says:\n%s", got)
|
||||
}
|
||||
// The framework is not an application and has no row.
|
||||
if strings.Contains(got, "[core] ") {
|
||||
t.Errorf("the framework was given an application summary:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintStatusNamesAFailedInstallAndWhereItStopped(t *testing.T) {
|
||||
apps := appRows(adminmodels.SysApp{
|
||||
AppCode: "crm", Version: "1.2.0", Status: adminmodels.AppFailed,
|
||||
FailedVersion: "crm-1786800002000",
|
||||
})
|
||||
var buf bytes.Buffer
|
||||
if err := printStatus(&buf, sampleEntries(), apps, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "[crm] 1.2.0 failed at crm-1786800002000") {
|
||||
t.Errorf("output:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A process killed partway leaves this, and nothing else records it.
|
||||
func TestPrintStatusNamesAnInstallThatDidNotFinish(t *testing.T) {
|
||||
apps := appRows(adminmodels.SysApp{AppCode: "crm", Version: "1.2.0", Status: adminmodels.AppInstalling})
|
||||
var buf bytes.Buffer
|
||||
if err := printStatus(&buf, sampleEntries(), apps, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "did not finish installing") {
|
||||
t.Errorf("output:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// An application whose code was taken out of the binary after its migration
|
||||
// records were removed has nothing left but a sys_app row. The migration rows
|
||||
// cannot report it at all.
|
||||
func TestPrintStatusListsAnAppWithNoMigrationsAtAll(t *testing.T) {
|
||||
apps := appRows(adminmodels.SysApp{AppCode: "billing", Version: "3.0.0", Status: adminmodels.AppInstalled})
|
||||
var buf bytes.Buffer
|
||||
if err := printStatus(&buf, sampleEntries(), apps, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "[billing] 3.0.0 installed") {
|
||||
t.Errorf("an application only sys_app knows about was not listed:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "no migrations registered in this binary and none recorded") {
|
||||
t.Errorf("the empty group needs to say why it is empty:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "across 3 app(s)") {
|
||||
t.Errorf("the count does not include it:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --app narrows both halves, or the report names one application and
|
||||
// summarises another.
|
||||
func TestPrintStatusFilterAppliesToSysAppToo(t *testing.T) {
|
||||
apps := appRows(
|
||||
adminmodels.SysApp{AppCode: "crm", Version: "1.2.0", Status: adminmodels.AppInstalled},
|
||||
adminmodels.SysApp{AppCode: "billing", Version: "3.0.0", Status: adminmodels.AppInstalled},
|
||||
)
|
||||
var buf bytes.Buffer
|
||||
if err := printStatus(&buf, sampleEntries(), apps, "crm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.String()
|
||||
if strings.Contains(got, "billing") {
|
||||
t.Errorf("--app crm listed billing:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "across 1 app(s)") {
|
||||
t.Errorf("output:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A database from before sys_app existed. The listing is what it always was,
|
||||
// rather than an error or an empty report.
|
||||
func TestPrintStatusWithoutSysApp(t *testing.T) {
|
||||
var withRows, without bytes.Buffer
|
||||
if err := printStatus(&without, sampleEntries(), nil, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := printStatus(&withRows, sampleEntries(), map[string]adminmodels.SysApp{}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if without.String() != withRows.String() {
|
||||
t.Errorf("an empty sys_app and no sys_app print differently:\n%s\n---\n%s", without.String(), withRows.String())
|
||||
}
|
||||
if !strings.Contains(without.String(), "[crm]\n") {
|
||||
t.Errorf("the header carries a summary it has no row for:\n%s", without.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
commonmodels "go-admin/common/models"
|
||||
)
|
||||
|
||||
// policyKey is one casbin_rule row identified the way casbin_rule is unique:
|
||||
// by its tuple, not by its id. Ids do not survive SysRole.Update, which
|
||||
// removes a role's policies and adds them back.
|
||||
type policyKey struct {
|
||||
Ptype string `gorm:"column:ptype"`
|
||||
V0 string `gorm:"column:v0"`
|
||||
V1 string `gorm:"column:v1"`
|
||||
V2 string `gorm:"column:v2"`
|
||||
V3 string `gorm:"column:v3"`
|
||||
V4 string `gorm:"column:v4"`
|
||||
V5 string `gorm:"column:v5"`
|
||||
}
|
||||
|
||||
func (p policyKey) String() string {
|
||||
return fmt.Sprintf("%s %s %s %s", p.Ptype, p.V0, p.V1, p.V2)
|
||||
}
|
||||
|
||||
// uninstallReport is what an uninstall removed, and what it deliberately did
|
||||
// not.
|
||||
type uninstallReport struct {
|
||||
Code string
|
||||
// Found says whether sys_app had a row. An application whose migrations
|
||||
// were applied by plain `migrate` rather than by `install` has its menus
|
||||
// and its permissions without ever having had one.
|
||||
Found bool
|
||||
Version string
|
||||
Menus int64
|
||||
Apis int64
|
||||
Bindings int64
|
||||
RoleMenus int64
|
||||
Policies int64
|
||||
Migrations int64
|
||||
// Skipped are ledger entries whose casbin_rule row was not there any
|
||||
// more: something this install created and something else removed.
|
||||
Skipped []policyKey
|
||||
// Orphans are policies naming this application's paths that no ledger
|
||||
// entry claims - somebody granted this app's API to another role by
|
||||
// hand. Reported, never deleted.
|
||||
Orphans []policyKey
|
||||
}
|
||||
|
||||
// uninstall removes one application's menus, APIs and permission grants.
|
||||
//
|
||||
// It does not touch the application's own tables. Removing an order module
|
||||
// is not the same decision as destroying the orders, and nothing here can
|
||||
// tell the operator apart from someone who will reinstall tomorrow.
|
||||
//
|
||||
// One transaction, and this one really is one: every statement below is DML
|
||||
// or a SELECT, so unlike an install there is no DDL to commit it out from
|
||||
// under itself. Child rows go first, while the ids that identify them can
|
||||
// still be read from the parents.
|
||||
//
|
||||
// A sys_app row is not required. `migrate` with no subcommand applies every
|
||||
// registered migration, an application's included, so an application can
|
||||
// have all of its data without ever having gone through the installer.
|
||||
func uninstall(db *gorm.DB, code string) (uninstallReport, error) {
|
||||
code = migration.NormalizeAppCode(code)
|
||||
rep := uninstallReport{Code: code}
|
||||
if code == "" {
|
||||
return rep, errors.New("no app code given")
|
||||
}
|
||||
if code == migration.FrameworkAppCode {
|
||||
return rep, fmt.Errorf("%q is the framework's own migrations; there is no uninstall for those", code)
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
row, found, err := loadApp(tx, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rep.Found = found
|
||||
if found {
|
||||
rep.Version = row.Version
|
||||
}
|
||||
|
||||
// 1 and 2. Read before deleting: sys_api's rows are about to go, and
|
||||
// step 5b needs their paths.
|
||||
//
|
||||
// Unscoped throughout. A row this application wrote that somebody
|
||||
// soft-deleted from the UI is still this application's row, and
|
||||
// leaving it behind would leave its join rows pointing at it.
|
||||
var menuIDs []int
|
||||
if err := tx.Unscoped().Model(&adminmodels.SysMenu{}).
|
||||
Where("app_code = ?", code).Pluck("menu_id", &menuIDs).Error; err != nil {
|
||||
return fmt.Errorf("reading this app's menus: %w", err)
|
||||
}
|
||||
var apiIDs []int
|
||||
if err := tx.Unscoped().Model(&adminmodels.SysApi{}).
|
||||
Where("app_code = ?", code).Pluck("id", &apiIDs).Error; err != nil {
|
||||
return fmt.Errorf("reading this app's apis: %w", err)
|
||||
}
|
||||
var apiKeys []policyKey
|
||||
if err := tx.Unscoped().Model(&adminmodels.SysApi{}).
|
||||
Where("app_code = ?", code).
|
||||
Select("path as v1, action as v2").Scan(&apiKeys).Error; err != nil {
|
||||
return fmt.Errorf("reading this app's api paths: %w", err)
|
||||
}
|
||||
|
||||
// 3. The many2many rows behind SysMenu.SysApi. Either side is enough
|
||||
// to make a row this application's.
|
||||
//
|
||||
// The guard is intent, not necessity: GORM renders IN with an empty
|
||||
// slice as a condition matching nothing rather than the empty IN
|
||||
// list raw SQL would reject, so removing it changes no behaviour
|
||||
// today. It says out loud that an application with no menus, or no
|
||||
// apis, is a normal thing to uninstall.
|
||||
if len(menuIDs) > 0 || len(apiIDs) > 0 {
|
||||
q := tx.Table("sys_menu_api_rule")
|
||||
switch {
|
||||
case len(menuIDs) > 0 && len(apiIDs) > 0:
|
||||
q = q.Where("sys_menu_menu_id IN ? OR sys_api_id IN ?", menuIDs, apiIDs)
|
||||
case len(menuIDs) > 0:
|
||||
q = q.Where("sys_menu_menu_id IN ?", menuIDs)
|
||||
default:
|
||||
q = q.Where("sys_api_id IN ?", apiIDs)
|
||||
}
|
||||
res := q.Delete(nil)
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("removing menu/api bindings: %w", res.Error)
|
||||
}
|
||||
rep.Bindings = res.RowsAffected
|
||||
}
|
||||
|
||||
// 4. Role assignments. menu_id is a surrogate key, so a row here can
|
||||
// only have come from a menu this application wrote - there is no
|
||||
// "looks like it but is not". That is why this needs no ledger, and
|
||||
// why a column on sys_role_menu would have been wrong: SysRole.Update
|
||||
// deletes a role's rows and writes them back through GORM's
|
||||
// many2many, which does not carry extra columns, so any such column
|
||||
// would be silently blanked the first time somebody edits a role.
|
||||
if len(menuIDs) > 0 {
|
||||
res := tx.Table("sys_role_menu").Where("menu_id IN ?", menuIDs).Delete(nil)
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("removing role assignments: %w", res.Error)
|
||||
}
|
||||
rep.RoleMenus = res.RowsAffected
|
||||
}
|
||||
|
||||
// 5. Policies, by ledger, one at a time and by exact tuple.
|
||||
var grants []adminmodels.SysAppCasbinGrant
|
||||
if err := tx.Where("app_code = ?", code).Find(&grants).Error; err != nil {
|
||||
return fmt.Errorf("reading the grant ledger: %w", err)
|
||||
}
|
||||
for _, g := range grants {
|
||||
k := policyKey{Ptype: g.Ptype, V0: g.V0, V1: g.V1, V2: g.V2, V3: g.V3, V4: g.V4, V5: g.V5}
|
||||
res := tx.Table("casbin_rule").
|
||||
Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ? AND v3 = ? AND v4 = ? AND v5 = ?",
|
||||
k.Ptype, k.V0, k.V1, k.V2, k.V3, k.V4, k.V5).
|
||||
Delete(nil)
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("removing policy %s: %w", k, res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// Something this install created is not there any more. Not
|
||||
// an error: the uninstall's job was to remove it and it is
|
||||
// gone. Reported because a policy this app created and did
|
||||
// not remove means something else rewrote casbin_rule.
|
||||
rep.Skipped = append(rep.Skipped, k)
|
||||
continue
|
||||
}
|
||||
rep.Policies += res.RowsAffected
|
||||
}
|
||||
// The ledger's job ends here whether or not each row matched. Left
|
||||
// behind it would only grow, and a reinstall writes its own entries.
|
||||
if err := tx.Where("app_code = ?", code).
|
||||
Delete(&adminmodels.SysAppCasbinGrant{}).Error; err != nil {
|
||||
return fmt.Errorf("clearing the grant ledger: %w", err)
|
||||
}
|
||||
|
||||
// 5b. Read-only. By now every policy the ledger could speak for has
|
||||
// been dealt with, so a policy still matching one of this app's paths
|
||||
// is one the ledger never claimed - somebody granted this app's API
|
||||
// to another role by hand. Business rule 3 says do not delete what
|
||||
// is not ours; without this step nobody would ever learn it is
|
||||
// there, pointing at an API that is about to stop existing.
|
||||
orphans, err := findOrphanPolicies(tx, apiKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rep.Orphans = orphans
|
||||
|
||||
// 6 and 7.
|
||||
res := tx.Unscoped().Where("app_code = ?", code).Delete(&adminmodels.SysApi{})
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("removing this app's apis: %w", res.Error)
|
||||
}
|
||||
rep.Apis = res.RowsAffected
|
||||
|
||||
res = tx.Unscoped().Where("app_code = ?", code).Delete(&adminmodels.SysMenu{})
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("removing this app's menus: %w", res.Error)
|
||||
}
|
||||
rep.Menus = res.RowsAffected
|
||||
|
||||
// 8. Without this a reinstall finds every version already applied,
|
||||
// runs no migration, seeds nothing, and reports success. It is the
|
||||
// easiest step to leave out, because a migration record does not
|
||||
// look like the application's data.
|
||||
res = tx.Where("app_code = ?", code).Delete(&commonmodels.Migration{})
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("removing this app's migration records: %w", res.Error)
|
||||
}
|
||||
rep.Migrations = res.RowsAffected
|
||||
|
||||
// 9.
|
||||
if found {
|
||||
if err := tx.Where("app_code = ?", code).
|
||||
Delete(&adminmodels.SysApp{}).Error; err != nil {
|
||||
return fmt.Errorf("removing the sys_app row: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return uninstallReport{Code: code}, err
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// findOrphanPolicies looks for policies naming any of this application's
|
||||
// paths.
|
||||
//
|
||||
// Written as an OR chain rather than a row-value IN, which MySQL and modern
|
||||
// SQLite accept and SQL Server does not; this repository supports all of
|
||||
// them. Chunked because a driver's placeholder limit is reached long before
|
||||
// an application runs out of endpoints.
|
||||
func findOrphanPolicies(tx *gorm.DB, keys []policyKey) ([]policyKey, error) {
|
||||
const chunk = 100
|
||||
var out []policyKey
|
||||
for start := 0; start < len(keys); start += chunk {
|
||||
end := start + chunk
|
||||
if end > len(keys) {
|
||||
end = len(keys)
|
||||
}
|
||||
clauses := make([]string, 0, end-start)
|
||||
args := make([]any, 0, (end-start)*2)
|
||||
for _, k := range keys[start:end] {
|
||||
clauses = append(clauses, "(v1 = ? AND v2 = ?)")
|
||||
args = append(args, k.V1, k.V2)
|
||||
}
|
||||
var found []policyKey
|
||||
if err := tx.Table("casbin_rule").
|
||||
Where("ptype = ? AND ("+strings.Join(clauses, " OR ")+")", append([]any{"p"}, args...)...).
|
||||
Scan(&found).Error; err != nil {
|
||||
return nil, fmt.Errorf("looking for policies nothing claims: %w", err)
|
||||
}
|
||||
out = append(out, found...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// reportUninstall prints what went and what stayed.
|
||||
//
|
||||
// The two lists are separate because they mean different things: one is
|
||||
// something this application created that had already gone, the other is
|
||||
// somebody else's grant that is now pointing at nothing. Merged into one
|
||||
// "could not remove" list, neither would be actionable.
|
||||
func reportUninstall(w io.Writer, rep uninstallReport) {
|
||||
if !rep.Found {
|
||||
fmt.Fprintf(w, "%s had no sys_app row; removed what its migrations had written\n", rep.Code)
|
||||
} else {
|
||||
fmt.Fprintf(w, "uninstalled %s %s\n", rep.Code, rep.Version)
|
||||
}
|
||||
fmt.Fprintf(w, "removed: %d menu(s), %d api(s), %d binding(s), %d role assignment(s), %d policy(ies), %d migration record(s)\n",
|
||||
rep.Menus, rep.Apis, rep.Bindings, rep.RoleMenus, rep.Policies, rep.Migrations)
|
||||
fmt.Fprintln(w, "the application's own tables were not touched.")
|
||||
|
||||
if len(rep.Skipped) > 0 {
|
||||
fmt.Fprintf(w, "\n%d policy(ies) this install had created were already gone:\n", len(rep.Skipped))
|
||||
for _, k := range rep.Skipped {
|
||||
fmt.Fprintf(w, " %s\n", k)
|
||||
}
|
||||
}
|
||||
if len(rep.Orphans) > 0 {
|
||||
fmt.Fprintf(w, "\n%d policy(ies) name this application's paths and were granted by somebody else, so they were left alone:\n", len(rep.Orphans))
|
||||
for _, k := range rep.Orphans {
|
||||
fmt.Fprintf(w, " %s\n", k)
|
||||
}
|
||||
fmt.Fprintln(w, "they now point at APIs that no longer exist. Harmless to the running server, and yours to clear up.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/seed"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
_ "go-admin/app/admin/service" // registers the seeder SeedMenus dispatches to
|
||||
"go-admin/cmd/migrate/migration"
|
||||
commonmodels "go-admin/common/models"
|
||||
)
|
||||
|
||||
const adminRoleKey = "admin"
|
||||
|
||||
// newUninstallDB builds every table an install writes to, plus one table
|
||||
// standing in for the application's own data, which an uninstall must not
|
||||
// touch.
|
||||
func newUninstallDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&adminmodels.SysMenu{}, &adminmodels.SysApi{}, &adminmodels.SysRole{},
|
||||
&adminmodels.SysApp{}, &adminmodels.SysAppCasbinGrant{}, &commonmodels.Migration{},
|
||||
); err != nil {
|
||||
t.Fatalf("automigrate: %v", err)
|
||||
}
|
||||
// casbin_rule has no GORM model in this repository; the columns are the
|
||||
// ones grantToAdminRole's INSERT addresses.
|
||||
if err := db.Exec(`CREATE TABLE casbin_rule (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ptype TEXT, v0 TEXT, v1 TEXT, v2 TEXT, v3 TEXT, v4 TEXT, v5 TEXT
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create casbin_rule: %v", err)
|
||||
}
|
||||
if err := db.Exec(`CREATE TABLE app_order (id INTEGER PRIMARY KEY, note TEXT)`).Error; err != nil {
|
||||
t.Fatalf("create app_order: %v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO app_order (id, note) VALUES (1, 'a real order')`).Error; err != nil {
|
||||
t.Fatalf("seed app_order: %v", err)
|
||||
}
|
||||
if err := db.Create(&adminmodels.SysRole{RoleName: "Administrator", RoleKey: adminRoleKey}).Error; err != nil {
|
||||
t.Fatalf("seed admin role: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// specsFor builds one application's menus and apis. The paths carry the app
|
||||
// code, because two applications do not share an endpoint - and if a fixture
|
||||
// let them, the second one's policies would already exist and its ledger
|
||||
// would legitimately come out empty, which would make it a useless control.
|
||||
func specsFor(code string) ([]seed.MenuSpec, []seed.ApiSpec) {
|
||||
menus := []seed.MenuSpec{
|
||||
{Code: "dir", Kind: "M", Title: code + " example", Path: "/apps/" + code, Component: "Layout", Sort: 10},
|
||||
{Code: "list", Parent: "dir", Kind: "C", Title: code, Path: "list", Component: "apps/" + code + "/index", Sort: 1, ApiCodes: []string{"list"}},
|
||||
}
|
||||
apis := []seed.ApiSpec{
|
||||
{Code: "list", Title: code + " list", Path: "/api/v1/" + code, Method: "GET", Handle: "apis." + code + ".GetPage-fm"},
|
||||
{Code: "create", Title: "create " + code, Path: "/api/v1/" + code, Method: "POST", Handle: "apis." + code + ".Insert-fm"},
|
||||
}
|
||||
return menus, apis
|
||||
}
|
||||
|
||||
// seedApp runs the real seeding path, so what the uninstaller has to undo is
|
||||
// what an install actually writes rather than a hand-built approximation.
|
||||
func seedApp(t *testing.T, db *gorm.DB, code string) {
|
||||
t.Helper()
|
||||
menus, apis := specsFor(code)
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return seed.SeedMenus(tx, code, menus, apis)
|
||||
}); err != nil {
|
||||
t.Fatalf("seeding %q: %v", code, err)
|
||||
}
|
||||
if err := db.Create(&commonmodels.Migration{
|
||||
Version: code + "-1786800001000", AppCode: code, ApplyTime: time.Now(),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("recording the migration for %q: %v", code, err)
|
||||
}
|
||||
appRow(t, db, code, adminmodels.AppInstalled)
|
||||
}
|
||||
|
||||
func count(t *testing.T, db *gorm.DB, table, where string, args ...any) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
q := db.Table(table)
|
||||
if where != "" {
|
||||
q = q.Where(where, args...)
|
||||
}
|
||||
if err := q.Count(&n).Error; err != nil {
|
||||
t.Fatalf("counting %s: %v", table, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// A3: everything the install wrote goes, and the application's own table does
|
||||
// not.
|
||||
func TestUninstallRemovesWhatWasSeededAndNothingElse(t *testing.T) {
|
||||
db := newUninstallDB(t)
|
||||
seedApp(t, db, "order")
|
||||
|
||||
if count(t, db, "sys_menu", "app_code = ?", "order") == 0 {
|
||||
t.Fatal("nothing was seeded, so this test proves nothing")
|
||||
}
|
||||
|
||||
rep, err := uninstall(db, "order")
|
||||
if err != nil {
|
||||
t.Fatalf("uninstall: %v", err)
|
||||
}
|
||||
if !rep.Found {
|
||||
t.Error("the sys_app row was not found")
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
table, where string
|
||||
args []any
|
||||
}{
|
||||
{"sys_menu", "app_code = ?", []any{"order"}},
|
||||
{"sys_api", "app_code = ?", []any{"order"}},
|
||||
{"sys_menu_api_rule", "", nil},
|
||||
{"sys_role_menu", "", nil},
|
||||
{"casbin_rule", "v1 = ?", []any{"/api/v1/order"}},
|
||||
{"sys_app_casbin_grant", "app_code = ?", []any{"order"}},
|
||||
{"sys_migration", "app_code = ?", []any{"order"}},
|
||||
{"sys_app", "app_code = ?", []any{"order"}},
|
||||
} {
|
||||
if n := count(t, db, c.table, c.where, c.args...); n != 0 {
|
||||
t.Errorf("%s still has %d row(s)", c.table, n)
|
||||
}
|
||||
}
|
||||
if n := count(t, db, "app_order", "", nil); n != 1 {
|
||||
t.Errorf("app_order has %d row(s); the application's own data is not the uninstaller's to remove", n)
|
||||
}
|
||||
if len(rep.Skipped) != 0 || len(rep.Orphans) != 0 {
|
||||
t.Errorf("a clean uninstall reported skipped=%v orphans=%v", rep.Skipped, rep.Orphans)
|
||||
}
|
||||
if rep.Menus == 0 || rep.Apis == 0 || rep.Policies == 0 || rep.Migrations == 0 {
|
||||
t.Errorf("the report says nothing was removed: %+v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
// Uninstalling one application must not reach into another's rows. Every
|
||||
// delete here is filtered, and a missing filter is invisible on a database
|
||||
// with only one application in it.
|
||||
func TestUninstallLeavesAnotherApplicationAlone(t *testing.T) {
|
||||
db := newUninstallDB(t)
|
||||
seedApp(t, db, "order")
|
||||
seedApp(t, db, "crm")
|
||||
|
||||
before := map[string]int64{
|
||||
"sys_menu": count(t, db, "sys_menu", "app_code = ?", "crm"),
|
||||
"sys_api": count(t, db, "sys_api", "app_code = ?", "crm"),
|
||||
"sys_app_casbin_grant": count(t, db, "sys_app_casbin_grant", "app_code = ?", "crm"),
|
||||
"sys_migration": count(t, db, "sys_migration", "app_code = ?", "crm"),
|
||||
"sys_app": count(t, db, "sys_app", "app_code = ?", "crm"),
|
||||
}
|
||||
for k, v := range before {
|
||||
if v == 0 {
|
||||
t.Fatalf("crm has no rows in %s, so this test proves nothing", k)
|
||||
}
|
||||
}
|
||||
crmBindings := count(t, db, "sys_menu_api_rule", "", nil)
|
||||
crmRoleMenus := count(t, db, "sys_role_menu", "", nil)
|
||||
|
||||
if _, err := uninstall(db, "order"); err != nil {
|
||||
t.Fatalf("uninstall: %v", err)
|
||||
}
|
||||
|
||||
for k, v := range before {
|
||||
if n := count(t, db, k, "app_code = ?", "crm"); n != v {
|
||||
t.Errorf("%s for crm went from %d to %d", k, v, n)
|
||||
}
|
||||
}
|
||||
// crm's own bindings and role rows are half of each total, and must be
|
||||
// exactly what is left.
|
||||
if n := count(t, db, "sys_menu_api_rule", "", nil); n != crmBindings/2 {
|
||||
t.Errorf("sys_menu_api_rule = %d, want %d (crm's half)", n, crmBindings/2)
|
||||
}
|
||||
if n := count(t, db, "sys_role_menu", "", nil); n != crmRoleMenus/2 {
|
||||
t.Errorf("sys_role_menu = %d, want %d (crm's half)", n, crmRoleMenus/2)
|
||||
}
|
||||
// crm's policies name a different path, so they are untouched.
|
||||
if n := count(t, db, "casbin_rule", "v1 = ?", "/api/v1/order"); n != 0 {
|
||||
t.Errorf("order's policies survived: %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A6b: somebody granted this application's API to another role by hand. That
|
||||
// grant is not in the ledger, is not this uninstall's to remove, and would
|
||||
// otherwise vanish from view entirely.
|
||||
func TestUninstallReportsAGrantSomebodyElseMade(t *testing.T) {
|
||||
db := newUninstallDB(t)
|
||||
seedApp(t, db, "order")
|
||||
|
||||
if err := db.Exec(
|
||||
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'ops', '/api/v1/order', 'GET', '', '', '')",
|
||||
).Error; err != nil {
|
||||
t.Fatalf("hand-made grant: %v", err)
|
||||
}
|
||||
|
||||
rep, err := uninstall(db, "order")
|
||||
if err != nil {
|
||||
t.Fatalf("uninstall: %v", err)
|
||||
}
|
||||
if n := count(t, db, "casbin_rule", "v0 = ?", "ops"); n != 1 {
|
||||
t.Errorf("somebody else's grant was deleted (%d rows left)", n)
|
||||
}
|
||||
if len(rep.Orphans) != 1 {
|
||||
t.Fatalf("orphans = %v, want the one hand-made grant", rep.Orphans)
|
||||
}
|
||||
if rep.Orphans[0].V0 != "ops" {
|
||||
t.Errorf("orphan = %+v", rep.Orphans[0])
|
||||
}
|
||||
// The admin grants it did own are gone.
|
||||
if n := count(t, db, "casbin_rule", "v0 = ?", adminRoleKey); n != 0 {
|
||||
t.Errorf("%d of this app's own policies survived", n)
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
reportUninstall(&out, rep)
|
||||
if !strings.Contains(out.String(), "ops") || !strings.Contains(out.String(), "left alone") {
|
||||
t.Errorf("the report does not say what was left behind: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A6a: a policy this install created is not there any more. Not an error -
|
||||
// the uninstall wanted it gone and it is - but reported, because something
|
||||
// else rewrote casbin_rule.
|
||||
func TestUninstallReportsALedgerEntryWhosePolicyIsGone(t *testing.T) {
|
||||
db := newUninstallDB(t)
|
||||
seedApp(t, db, "order")
|
||||
|
||||
if err := db.Exec("DELETE FROM casbin_rule WHERE v2 = 'POST'").Error; err != nil {
|
||||
t.Fatalf("removing a policy: %v", err)
|
||||
}
|
||||
|
||||
rep, err := uninstall(db, "order")
|
||||
if err != nil {
|
||||
t.Fatalf("a missing policy made the uninstall fail: %v", err)
|
||||
}
|
||||
if len(rep.Skipped) != 1 {
|
||||
t.Fatalf("skipped = %v, want the one that had gone", rep.Skipped)
|
||||
}
|
||||
if rep.Skipped[0].V2 != "POST" {
|
||||
t.Errorf("skipped = %+v", rep.Skipped[0])
|
||||
}
|
||||
if n := count(t, db, "sys_app_casbin_grant", "", nil); n != 0 {
|
||||
t.Errorf("the ledger kept %d row(s); its job ends with the uninstall", n)
|
||||
}
|
||||
// It still committed: a skip is a reported branch, not a failure.
|
||||
if n := count(t, db, "sys_menu", "app_code = ?", "order"); n != 0 {
|
||||
t.Errorf("the transaction rolled back over a skip: sys_menu has %d row(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// G5/A4: without this the reinstall finds every version applied, runs no
|
||||
// migration, seeds nothing, and reports success.
|
||||
func TestUninstallClearsThisAppsMigrationRecordsOnly(t *testing.T) {
|
||||
db := newUninstallDB(t)
|
||||
seedApp(t, db, "order")
|
||||
if err := db.Create(&commonmodels.Migration{
|
||||
Version: "1786700001000", AppCode: "", ApplyTime: time.Now(),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("framework migration row: %v", err)
|
||||
}
|
||||
|
||||
if _, err := uninstall(db, "order"); err != nil {
|
||||
t.Fatalf("uninstall: %v", err)
|
||||
}
|
||||
if n := count(t, db, "sys_migration", "app_code = ?", "order"); n != 0 {
|
||||
t.Errorf("sys_migration still has %d row(s) for order; a reinstall would seed nothing", n)
|
||||
}
|
||||
if n := count(t, db, "sys_migration", "app_code = ?", ""); n != 1 {
|
||||
t.Errorf("the framework's own migration record was removed (%d left)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A11: sys_role_menu is found by menu id, not by a column on it. A column
|
||||
// would have been blanked the first time somebody edited a role, because
|
||||
// SysRole.Update deletes the role's rows and writes them back through GORM's
|
||||
// many2many, which does not carry extra columns. This reproduces that edit.
|
||||
func TestUninstallSurvivesARoleMenuRewrite(t *testing.T) {
|
||||
db := newUninstallDB(t)
|
||||
seedApp(t, db, "order")
|
||||
|
||||
var roleID int
|
||||
if err := db.Model(&adminmodels.SysRole{}).Where("role_key = ?", adminRoleKey).
|
||||
Pluck("role_id", &roleID).Error; err != nil {
|
||||
t.Fatalf("reading the admin role: %v", err)
|
||||
}
|
||||
var menuIDs []int
|
||||
if err := db.Model(&adminmodels.SysMenu{}).Where("app_code = ?", "order").
|
||||
Pluck("menu_id", &menuIDs).Error; err != nil {
|
||||
t.Fatalf("reading menus: %v", err)
|
||||
}
|
||||
if len(menuIDs) == 0 {
|
||||
t.Fatal("no menus were seeded")
|
||||
}
|
||||
// What SysRole.Update does: drop every row for the role, then write them
|
||||
// back with nothing but the two keys.
|
||||
if err := db.Exec("DELETE FROM sys_role_menu WHERE role_id = ?", roleID).Error; err != nil {
|
||||
t.Fatalf("clearing role menus: %v", err)
|
||||
}
|
||||
for _, id := range menuIDs {
|
||||
if err := db.Exec("INSERT INTO sys_role_menu (role_id, menu_id) VALUES (?, ?)", roleID, id).Error; err != nil {
|
||||
t.Fatalf("rewriting role menus: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
rep, err := uninstall(db, "order")
|
||||
if err != nil {
|
||||
t.Fatalf("uninstall: %v", err)
|
||||
}
|
||||
if rep.RoleMenus != int64(len(menuIDs)) {
|
||||
t.Errorf("removed %d role assignment(s), want %d", rep.RoleMenus, len(menuIDs))
|
||||
}
|
||||
if n := count(t, db, "sys_role_menu", "", nil); n != 0 {
|
||||
t.Errorf("sys_role_menu still has %d row(s) after a role edit", n)
|
||||
}
|
||||
}
|
||||
|
||||
// `migrate` with no subcommand applies every registered migration, an
|
||||
// application's included, so an application can have all of its rows and
|
||||
// never have had a sys_app row. Refusing to clean that up would leave the
|
||||
// only case where nothing else can.
|
||||
func TestUninstallWorksWithoutASysAppRow(t *testing.T) {
|
||||
db := newUninstallDB(t)
|
||||
seedApp(t, db, "order")
|
||||
if err := db.Where("app_code = ?", "order").Delete(&adminmodels.SysApp{}).Error; err != nil {
|
||||
t.Fatalf("removing the sys_app row: %v", err)
|
||||
}
|
||||
|
||||
rep, err := uninstall(db, "order")
|
||||
if err != nil {
|
||||
t.Fatalf("uninstall: %v", err)
|
||||
}
|
||||
if rep.Found {
|
||||
t.Error("the report claims a sys_app row that was not there")
|
||||
}
|
||||
if n := count(t, db, "sys_menu", "app_code = ?", "order"); n != 0 {
|
||||
t.Errorf("sys_menu still has %d row(s)", n)
|
||||
}
|
||||
var out strings.Builder
|
||||
reportUninstall(&out, rep)
|
||||
if !strings.Contains(out.String(), "no sys_app row") {
|
||||
t.Errorf("the report does not say the row was missing: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// One transaction, and it really is one: nothing here runs DDL, so unlike an
|
||||
// install there is nothing to commit it out from under itself.
|
||||
func TestUninstallRollsBackAsAWhole(t *testing.T) {
|
||||
db := newUninstallDB(t)
|
||||
seedApp(t, db, "order")
|
||||
menusBefore := count(t, db, "sys_menu", "app_code = ?", "order")
|
||||
policiesBefore := count(t, db, "casbin_rule", "", nil)
|
||||
|
||||
// Step 8's table is gone, so the uninstall fails after it has already
|
||||
// deleted menus, apis, bindings and policies.
|
||||
if err := db.Migrator().DropTable(&commonmodels.Migration{}); err != nil {
|
||||
t.Fatalf("dropping sys_migration: %v", err)
|
||||
}
|
||||
|
||||
if _, err := uninstall(db, "order"); err == nil {
|
||||
t.Fatal("the uninstall reported success with sys_migration missing")
|
||||
}
|
||||
if n := count(t, db, "sys_menu", "app_code = ?", "order"); n != menusBefore {
|
||||
t.Errorf("sys_menu = %d, want %d: the failed uninstall did not roll back", n, menusBefore)
|
||||
}
|
||||
if n := count(t, db, "casbin_rule", "", nil); n != policiesBefore {
|
||||
t.Errorf("casbin_rule = %d, want %d: the failed uninstall did not roll back", n, policiesBefore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallRefusesTheFrameworkCode(t *testing.T) {
|
||||
db := newUninstallDB(t)
|
||||
if _, err := uninstall(db, migration.FrameworkAppCode); err == nil {
|
||||
t.Fatal("the framework was uninstalled")
|
||||
}
|
||||
}
|
||||
|
||||
// findOrphanPolicies chunks its OR chain because a driver runs out of
|
||||
// placeholders long before an application runs out of endpoints. The
|
||||
// boundary is where an off-by-one hides: a chunk size that drops the last
|
||||
// element of each batch, or one that never advances, both leave policies
|
||||
// unreported and nothing says so.
|
||||
func TestFindOrphanPoliciesCoversEveryPathAcrossChunks(t *testing.T) {
|
||||
db := newUninstallDB(t)
|
||||
|
||||
// Deliberately not a multiple of the chunk size, so the last batch is
|
||||
// short, and large enough to need three of them.
|
||||
const n = 205
|
||||
keys := make([]policyKey, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
path := "/api/v1/thing" + strconv.Itoa(i)
|
||||
keys = append(keys, policyKey{V1: path, V2: "GET"})
|
||||
if err := db.Exec(
|
||||
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'ops', ?, 'GET', '', '', '')",
|
||||
path,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seeding policy %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// One policy that must not match: a path no key names.
|
||||
if err := db.Exec(
|
||||
"INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'ops', '/api/v1/elsewhere', 'GET', '', '', '')",
|
||||
).Error; err != nil {
|
||||
t.Fatalf("seeding the control policy: %v", err)
|
||||
}
|
||||
|
||||
found, err := findOrphanPolicies(db, keys)
|
||||
if err != nil {
|
||||
t.Fatalf("findOrphanPolicies: %v", err)
|
||||
}
|
||||
if len(found) != n {
|
||||
t.Fatalf("found %d policies, want %d", len(found), n)
|
||||
}
|
||||
seen := make(map[string]bool, len(found))
|
||||
for _, f := range found {
|
||||
seen[f.V1] = true
|
||||
if f.V1 == "/api/v1/elsewhere" {
|
||||
t.Error("a path no key names was reported")
|
||||
}
|
||||
}
|
||||
for _, k := range keys {
|
||||
if !seen[k.V1] {
|
||||
t.Errorf("%s was not reported", k.V1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An application may register apis with no menus at all - endpoints another
|
||||
// service calls - so either of the id lists an uninstall reads can be empty.
|
||||
// The guard in front of the join-table delete turns out not to be what makes
|
||||
// this work: GORM renders IN with an empty slice as a condition that matches
|
||||
// nothing, rather than the empty IN list that would be a syntax error in raw
|
||||
// SQL, and removing the guard leaves this test green. It stays as an explicit
|
||||
// statement of intent rather than a reliance on that rendering.
|
||||
func TestUninstallWithApisButNoMenus(t *testing.T) {
|
||||
db := newUninstallDB(t)
|
||||
apis := []seed.ApiSpec{
|
||||
{Code: "hook", Title: "Inbound hook", Path: "/api/v1/hook", Method: "POST", Handle: "hook.Receive"},
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return seed.SeedMenus(tx, "hooks", nil, apis)
|
||||
}); err != nil {
|
||||
t.Fatalf("seeding: %v", err)
|
||||
}
|
||||
|
||||
rep, err := uninstall(db, "hooks")
|
||||
if err != nil {
|
||||
t.Fatalf("uninstall: %v", err)
|
||||
}
|
||||
if rep.Apis != 1 {
|
||||
t.Errorf("removed %d api(s), want 1", rep.Apis)
|
||||
}
|
||||
if rep.Policies != 1 {
|
||||
t.Errorf("removed %d policy(ies), want 1", rep.Policies)
|
||||
}
|
||||
if n := count(t, db, "casbin_rule", "", nil); n != 0 {
|
||||
t.Errorf("casbin_rule has %d row(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror case: menus and no apis at all.
|
||||
func TestUninstallWithMenusButNoApis(t *testing.T) {
|
||||
db := newUninstallDB(t)
|
||||
menus := []seed.MenuSpec{
|
||||
{Code: "dir", Kind: "M", Title: "Reports", Path: "/apps/reports", Component: "Layout", Sort: 10},
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return seed.SeedMenus(tx, "reports", menus, nil)
|
||||
}); err != nil {
|
||||
t.Fatalf("seeding: %v", err)
|
||||
}
|
||||
|
||||
rep, err := uninstall(db, "reports")
|
||||
if err != nil {
|
||||
t.Fatalf("uninstall: %v", err)
|
||||
}
|
||||
if rep.Menus != 1 {
|
||||
t.Errorf("removed %d menu(s), want 1", rep.Menus)
|
||||
}
|
||||
if n := count(t, db, "sys_menu", "app_code = ?", "reports"); n != 0 {
|
||||
t.Errorf("sys_menu has %d row(s)", n)
|
||||
}
|
||||
if len(rep.Orphans) != 0 {
|
||||
t.Errorf("an application with no apis reported orphans: %v", rep.Orphans)
|
||||
}
|
||||
}
|
||||
@@ -45,8 +45,8 @@ func (e *QiNiuKODO) getToken() (string, error) {
|
||||
return putPolicy.UploadToken(mac), nil
|
||||
}
|
||||
|
||||
//Setup 装载
|
||||
//endpoint sss
|
||||
// Setup 装载
|
||||
// endpoint sss
|
||||
func (e *QiNiuKODO) Setup(endpoint, accessKeyID, accessKeySecret, BucketName string, options ...ClientOption) error {
|
||||
|
||||
mac := qbox.NewMac(accessKeyID, accessKeySecret)
|
||||
|
||||
@@ -10,8 +10,8 @@ type ALiYunOSS struct {
|
||||
BucketName string
|
||||
}
|
||||
|
||||
//Setup 装载
|
||||
//endpoint sss
|
||||
// Setup 装载
|
||||
// endpoint sss
|
||||
func (e *ALiYunOSS) Setup(endpoint, accessKeyID, accessKeySecret, BucketName string, options ...ClientOption) error {
|
||||
client, err := oss.New(endpoint, accessKeyID, accessKeySecret)
|
||||
if err != nil {
|
||||
|
||||
+55
-1
@@ -41,6 +41,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -68,6 +69,55 @@ type Check struct {
|
||||
Err string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// extra are checks a host registers that this package cannot make itself.
|
||||
//
|
||||
// The direction is why this exists. Whether the schema matches what the binary
|
||||
// expects is answered by the migration registry, which lives under cmd/ - and
|
||||
// common/ has never imported cmd/. Rather than start, the host registers the
|
||||
// check from where both are already in scope.
|
||||
var (
|
||||
extraMu sync.RWMutex
|
||||
extra []namedCheck
|
||||
)
|
||||
|
||||
type namedCheck struct {
|
||||
name string
|
||||
fn func(context.Context) error
|
||||
}
|
||||
|
||||
// Register adds a check to what Ready asks.
|
||||
//
|
||||
// It panics on a duplicate name rather than replacing or appending: two checks
|
||||
// under one name make the failing one impossible to identify from the response,
|
||||
// and registering the same one twice is a wiring mistake worth hearing about at
|
||||
// start-up rather than never.
|
||||
func Register(name string, fn func(context.Context) error) {
|
||||
if name == "" {
|
||||
panic("health: a registered check needs a name")
|
||||
}
|
||||
if fn == nil {
|
||||
panic("health: check " + name + " is nil")
|
||||
}
|
||||
extraMu.Lock()
|
||||
defer extraMu.Unlock()
|
||||
for _, c := range extra {
|
||||
if c.name == name {
|
||||
panic("health: check " + name + " is already registered")
|
||||
}
|
||||
}
|
||||
extra = append(extra, namedCheck{name: name, fn: fn})
|
||||
}
|
||||
|
||||
// registered returns the checks a host has added, copied so that Ready is not
|
||||
// iterating the slice while another goroutine appends to it.
|
||||
func registered() []namedCheck {
|
||||
extraMu.RLock()
|
||||
defer extraMu.RUnlock()
|
||||
out := make([]namedCheck, len(extra))
|
||||
copy(out, extra)
|
||||
return out
|
||||
}
|
||||
|
||||
// Ready asks every dependency this process cannot serve a request without.
|
||||
//
|
||||
// The queue is deliberately absent. Nothing on AdapterQueue answers "are you
|
||||
@@ -75,10 +125,14 @@ type Check struct {
|
||||
// a queue that is down degrades logging rather than stopping requests - which
|
||||
// is a reason to alert, not a reason to leave the load balancer pool.
|
||||
func Ready(ctx context.Context) []Check {
|
||||
return []Check{
|
||||
checks := []Check{
|
||||
safely("database", func() error { return pingDB(ctx) }),
|
||||
safely("cache", probeCache),
|
||||
}
|
||||
for _, c := range registered() {
|
||||
checks = append(checks, safely(c.name, func() error { return c.fn(ctx) }))
|
||||
}
|
||||
return checks
|
||||
}
|
||||
|
||||
// safely turns a panic into a failed check.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// isolate empties the registered checks and puts them back, so one test in
|
||||
// this package cannot decide what the next one sees.
|
||||
func isolate(t *testing.T) {
|
||||
t.Helper()
|
||||
extraMu.Lock()
|
||||
previous := extra
|
||||
extra = nil
|
||||
extraMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
extraMu.Lock()
|
||||
extra = previous
|
||||
extraMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func findCheck(checks []Check, name string) (Check, bool) {
|
||||
for _, c := range checks {
|
||||
if c.Name == name {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return Check{}, false
|
||||
}
|
||||
|
||||
// A registered check has to reach Ready's answer, or the host has wired
|
||||
// something that never gets asked.
|
||||
func TestARegisteredCheckIsAsked(t *testing.T) {
|
||||
isolate(t)
|
||||
Register("schema", func(context.Context) error { return errors.New("two behind") })
|
||||
|
||||
checks := Ready(context.Background())
|
||||
c, ok := findCheck(checks, "schema")
|
||||
if !ok {
|
||||
t.Fatal("Ready did not ask the registered check")
|
||||
}
|
||||
if c.OK {
|
||||
t.Error("the check returned an error and was still reported OK")
|
||||
}
|
||||
if c.Err != "two behind" {
|
||||
t.Errorf("Err = %q, want the check's own message", c.Err)
|
||||
}
|
||||
if Healthy(checks) {
|
||||
t.Error("Healthy said yes while a registered check was failing")
|
||||
}
|
||||
}
|
||||
|
||||
// The context Ready is given has to reach the check: it carries the probe's
|
||||
// deadline, and a check that ignores it can hold the handler past it.
|
||||
func TestTheRegisteredCheckIsGivenReadysContext(t *testing.T) {
|
||||
isolate(t)
|
||||
type key struct{}
|
||||
Register("ctx", func(ctx context.Context) error {
|
||||
if ctx.Value(key{}) != "carried" {
|
||||
return errors.New("the check was handed a different context")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
checks := Ready(context.WithValue(context.Background(), key{}, "carried"))
|
||||
c, ok := findCheck(checks, "ctx")
|
||||
if !ok {
|
||||
t.Fatal("the registered check was not asked")
|
||||
}
|
||||
if !c.OK {
|
||||
t.Errorf("check failed: %s", c.Err)
|
||||
}
|
||||
}
|
||||
|
||||
// A check that panics must not take the process down through the probe, the
|
||||
// same guarantee the built-in checks have.
|
||||
func TestARegisteredCheckThatPanicsFailsRatherThanCrashes(t *testing.T) {
|
||||
isolate(t)
|
||||
Register("boom", func(context.Context) error { panic("registry unreachable") })
|
||||
|
||||
checks := Ready(context.Background())
|
||||
c, ok := findCheck(checks, "boom")
|
||||
if !ok {
|
||||
t.Fatal("the registered check was not asked")
|
||||
}
|
||||
if c.OK {
|
||||
t.Error("a panicking check was reported OK")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteringTheSameNameTwicePanics(t *testing.T) {
|
||||
isolate(t)
|
||||
Register("dup", func(context.Context) error { return nil })
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("registering a duplicate name did not panic; two checks under " +
|
||||
"one name make the failing one impossible to identify")
|
||||
}
|
||||
}()
|
||||
Register("dup", func(context.Context) error { return nil })
|
||||
}
|
||||
|
||||
func TestRegisterRefusesAnEmptyNameOrNilCheck(t *testing.T) {
|
||||
isolate(t)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fn func(context.Context) error
|
||||
why string
|
||||
}{
|
||||
{"", func(context.Context) error { return nil }, "empty name"},
|
||||
{"nilfn", nil, "nil function"},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s did not panic", tc.why)
|
||||
}
|
||||
}()
|
||||
Register(tc.name, tc.fn)
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// NoCache is a middleware function that appends headers
|
||||
// to prevent the client from caching the HTTP response.
|
||||
func NoCache(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate, value")
|
||||
c.Header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate")
|
||||
c.Header("Expires", "Thu, 01 Jan 1970 00:00:00 GMT")
|
||||
c.Header("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
||||
c.Next()
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestNoCache(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
NoCache(c)
|
||||
|
||||
if got := w.Header().Get("Cache-Control"); got != "no-cache, no-store, max-age=0, must-revalidate" {
|
||||
t.Errorf("Cache-Control = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Expires"); got != "Thu, 01 Jan 1970 00:00:00 GMT" {
|
||||
t.Errorf("Expires = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Last-Modified"); got == "" {
|
||||
t.Error("Last-Modified should not be empty")
|
||||
} else if _, err := time.Parse(http.TimeFormat, got); err != nil {
|
||||
t.Errorf("Last-Modified = %q is not a valid HTTP time: %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptions(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("OPTIONS request", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodOptions, "/", nil)
|
||||
|
||||
Options(c)
|
||||
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
|
||||
t.Errorf("Access-Control-Allow-Origin = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Access-Control-Allow-Methods"); got != "GET,POST,PUT,PATCH,DELETE,OPTIONS" {
|
||||
t.Errorf("Access-Control-Allow-Methods = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Access-Control-Allow-Headers"); got != "authorization, origin, content-type, accept" {
|
||||
t.Errorf("Access-Control-Allow-Headers = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Allow"); got != "HEAD,GET,POST,PUT,PATCH,DELETE,OPTIONS" {
|
||||
t.Errorf("Allow = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Content-Type"); got != "application/json" {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
if !c.IsAborted() {
|
||||
t.Error("expected the request to be aborted")
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-OPTIONS request", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
Options(c)
|
||||
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("Access-Control-Allow-Origin = %q, want empty", got)
|
||||
}
|
||||
if c.IsAborted() {
|
||||
t.Error("expected the request not to be aborted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSecure(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("without TLS", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
Secure(c)
|
||||
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
|
||||
t.Errorf("Access-Control-Allow-Origin = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||
t.Errorf("X-Content-Type-Options = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("X-XSS-Protection"); got != "1; mode=block" {
|
||||
t.Errorf("X-XSS-Protection = %q", got)
|
||||
}
|
||||
if got := w.Header().Get("Strict-Transport-Security"); got != "" {
|
||||
t.Errorf("Strict-Transport-Security = %q, want empty without TLS", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with TLS", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
c.Request.TLS = &tls.ConnectionState{}
|
||||
|
||||
Secure(c)
|
||||
|
||||
if got := w.Header().Get("Strict-Transport-Security"); got != "max-age=31536000" {
|
||||
t.Errorf("Strict-Transport-Security = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -38,6 +38,6 @@ var CasbinExclude = []UrlInfo{
|
||||
{Url: "/", Method: "GET"},
|
||||
{Url: "/api/v1/server-monitor", Method: "GET"},
|
||||
{Url: "/api/v1/public/uploadFile", Method: "POST"},
|
||||
{Url: "/api/v1/user/pwd/set", Method: "PUT"},
|
||||
{Url: "/api/v1/user/pwd/set", Method: "PUT"},
|
||||
{Url: "/api/v1/sys-user", Method: "PUT"},
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
module github.com/go-admin-team/example-app-order
|
||||
|
||||
go 1.25.13
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.5.0
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.10.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.5.0 h1:aD1SALklBxizGB9u8cOgm4OT8z656FM83F4fD6dMz9g=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.5.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.10.0 h1:MM1wl9s2iW3M4GFsT8SyaUWaj2Q8m1NSuundXc6DWzI=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.10.0/go.mod h1:Q0FfO+8pfPNkPqk9fcRpe2sSOHfir82cjZO8Nol/8SI=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"github.com/go-admin-team/go-admin-core/v2/sdk/contract/app"
|
||||
)
|
||||
|
||||
// Version is what this application calls itself. A host's installer records
|
||||
// it, compares it against what is already installed to tell an upgrade from a
|
||||
// downgrade, and shows it in `migrate status`.
|
||||
//
|
||||
// It is not the same thing as the migration version above, and the two move
|
||||
// independently: adding a migration file without changing what the
|
||||
// application is called is normal, and so is a release that changes no
|
||||
// schema. The migration versions decide what runs; this decides what the
|
||||
// installed row says.
|
||||
const Version = "1.0.0"
|
||||
|
||||
// The manifest is registered from this package rather than one of its own
|
||||
// because this is the package a host has to import for the application to
|
||||
// exist at all - the migrations register here too. A second package would be
|
||||
// a second thing to remember to import, and forgetting it would leave an
|
||||
// application whose migrations run and which no installer can name.
|
||||
func init() {
|
||||
app.Register(app.Manifest{
|
||||
Code: AppCode,
|
||||
Name: "Order example",
|
||||
Version: Version,
|
||||
Description: "A worked example of an application that ships its own tables, menus and APIs.",
|
||||
Author: "go-admin",
|
||||
// Nothing yet. When an application does declare dependencies, a host
|
||||
// refuses to install it until they are installed - it does not
|
||||
// install them for you, because the blast radius of installing one
|
||||
// application should not be "and everything it happens to name".
|
||||
Requires: nil,
|
||||
// Reserved. A host stores both and interprets neither.
|
||||
Pricing: "",
|
||||
License: "MIT",
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
module go-admin
|
||||
|
||||
go 1.26.5
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/alibaba/sentinel-golang v1.0.4
|
||||
@@ -11,7 +11,7 @@ require (
|
||||
github.com/casbin/casbin/v3 v3.8.1
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.7.0
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible
|
||||
github.com/mssola/user_agent v0.6.0
|
||||
|
||||
@@ -145,8 +145,8 @@ github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.7.0 h1:1qV0/5iFBvkE3BRtm4ip0v0QYG9Fgx4UtOTd8zkQT9c=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.7.0/go.mod h1:LG/XvEfOplbuadKrPTPm0Nu5pN06aQUNZZC3ao4B4gs=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.10.0 h1:MM1wl9s2iW3M4GFsT8SyaUWaj2Q8m1NSuundXc6DWzI=
|
||||
github.com/go-admin-team/go-admin-core/v2 v2.10.0/go.mod h1:Q0FfO+8pfPNkPqk9fcRpe2sSOHfir82cjZO8Nol/8SI=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
|
||||
|
||||
+10
-12
@@ -23,20 +23,18 @@ metadata:
|
||||
version: v1
|
||||
spec:
|
||||
# One replica, and the drain window below buys nothing at one replica: there
|
||||
# is nowhere to send the traffic this pod stops taking. Raising it needs two
|
||||
# changes that are not this number:
|
||||
# is nowhere to send the traffic this pod stops taking.
|
||||
#
|
||||
# The volume below is shared by every replica, and the log path in
|
||||
# settings.yml lives on it, so a second pod would append to the same
|
||||
# rotating file.
|
||||
# The scheduler no longer stands in the way of raising this. Every pod takes
|
||||
# a lease row in its own database (sys_job_lease) and only the holder
|
||||
# registers the jobs, so one enabled job fires once however many pods there
|
||||
# are; a pod that loses the lease stops scheduling, and one that exits hands
|
||||
# it back so a successor starts without waiting out the lease. See #915.
|
||||
#
|
||||
# The job scheduler is per process while its handle on a job is one shared
|
||||
# column. Startup runs `UPDATE sys_job SET entry_id = 0 WHERE entry_id > 0`
|
||||
# across the whole table (app/jobs/jobbase.go), so a second pod erases the
|
||||
# first pod's ids and writes its own, and every pod registers the whole
|
||||
# enabled list in its own scheduler. Neither symptom logs anything: an
|
||||
# enabled job fires once per pod, and stopping one from the UI removes an
|
||||
# entry from the wrong process and still answers 200. See #915.
|
||||
# What still does stand in the way: the volume below is shared by every
|
||||
# replica, and the log path in settings.yml lives on it, so a second pod
|
||||
# appends to the same rotating file. Give each replica its own log
|
||||
# destination before raising this.
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 查询{{.ClassName}}列表
|
||||
export function list{{.ClassName}}(query) {
|
||||
return request({
|
||||
url: '/api/v1/{{.ModuleName}}',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 查询{{.ClassName}}详细
|
||||
export function get{{.ClassName}} ({{.PkJsonField}}) {
|
||||
return request({
|
||||
url: '/api/v1/{{.ModuleName}}/' + {{.PkJsonField}},
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 新增{{.ClassName}}
|
||||
export function add{{.ClassName}}(data) {
|
||||
return request({
|
||||
url: '/api/v1/{{.ModuleName}}',
|
||||
method: 'post',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 修改{{.ClassName}}
|
||||
export function update{{.ClassName}}(data) {
|
||||
return request({
|
||||
url: '/api/v1/{{.ModuleName}}/'+data.{{.PkJsonField}},
|
||||
method: 'put',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 删除{{.ClassName}}
|
||||
export function del{{.ClassName}}(data) {
|
||||
return request({
|
||||
url: '/api/v1/{{.ModuleName}}',
|
||||
method: 'delete',
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
{{- range $i, $col := .Columns}}
|
||||
{{- if $i}},{{end}}
|
||||
{{$col.JsonField}}: {{if $col.ColumnComment}}{{singleQuote $col.ColumnComment}}{{else}}{{singleQuote $col.JsonField}}{{end}}
|
||||
{{- end}}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
{{- range $i, $col := .Columns}}
|
||||
{{- if $i}},{{end}}
|
||||
{{$col.JsonField}}: {{if $col.ColumnComment}}{{singleQuote $col.ColumnComment}}{{else}}{{singleQuote $col.JsonField}}{{end}}
|
||||
{{- end}}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
{{- /*
|
||||
$pkType: the primary key's TS type for get{ClassName}'s parameter.
|
||||
Defaults to "number" - true for every column but string primary keys
|
||||
(natural keys), which do exist (sys_tables.go:323-338 gives a primary
|
||||
key column GoType "string" whenever its ColumnType is not int-shaped).
|
||||
Matches vue.go.template's own $pkType derivation exactly (F4) - useForm
|
||||
there is typed on the same column, and a mismatch between the two is a
|
||||
TS compile error at the call site, not a runtime bug.
|
||||
*/ -}}
|
||||
{{- $pkType := "number" -}}
|
||||
{{- $hasQuery := false -}}
|
||||
{{- range .Columns -}}
|
||||
{{- if and .Pk (eq .GoType "string") }}{{$pkType = "string"}}{{end -}}
|
||||
{{- if eq .IsQuery "1" }}{{$hasQuery = true}}{{end -}}
|
||||
{{- end -}}
|
||||
import request from '@/utils/request'
|
||||
import type { ApiResponse, PageQuery, PageResult, Id } from '@/types/api'
|
||||
|
||||
export interface {{.ClassName}} {
|
||||
{{- range .Columns}}
|
||||
{{.JsonField}}?: {{if eq .GoType "int" -}}
|
||||
number
|
||||
{{- else if eq .GoType "int64" -}}
|
||||
number
|
||||
{{- else if eq .GoType "float32" -}}
|
||||
number
|
||||
{{- else if eq .GoType "float64" -}}
|
||||
number
|
||||
{{- else -}}
|
||||
string
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
|
||||
{{if $hasQuery -}}
|
||||
export interface {{.ClassName}}Query {
|
||||
{{- range .Columns}}
|
||||
{{- if eq .IsQuery "1"}}
|
||||
{{.JsonField}}?: {{if eq .GoType "int" -}}
|
||||
number
|
||||
{{- else if eq .GoType "int64" -}}
|
||||
number
|
||||
{{- else if eq .GoType "float32" -}}
|
||||
number
|
||||
{{- else if eq .GoType "float64" -}}
|
||||
number
|
||||
{{- else -}}
|
||||
string
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- else -}}
|
||||
{{- /*
|
||||
No column is marked IsQuery - a plain display table with no search form
|
||||
is a normal shape, not an edge case, so this still has to produce a type
|
||||
useTable<Row, Query>/list{ClassName}(query: Query & PageQuery) can use.
|
||||
`export interface {ClassName}Query {}` is what naturally falls out of the
|
||||
range above finding nothing to iterate, but an empty interface trips
|
||||
@typescript-eslint/no-empty-object-type and fails pnpm lint.
|
||||
|
||||
Record<string, never> (this file's first attempt, and the type
|
||||
useTable.ts's own `TQuery extends object = Record<string, never>` default
|
||||
uses) looks like the obvious match but is wrong here: it is a mapped type
|
||||
over *every* string key, each mapped to never, so intersecting it with
|
||||
PageQuery does not leave PageQuery alone - `pageIndex` becomes
|
||||
`never & number`, i.e. never, and no value can be passed for it at all.
|
||||
useTable.ts itself never hits this because its one internal use of
|
||||
`TQuery & PageQuery` goes through an `as` cast rather than a structural
|
||||
check (composables/useTable.ts ~line 160); code that builds the object
|
||||
literal directly - such as a foreign-key column's
|
||||
`list{FkClass}({ pageIndex: 1, pageSize: 100 })` call in vue.go.template -
|
||||
is not casting anything and hits the real error, only when the referenced
|
||||
table happens to have no query columns of its own (a plain lookup/dict
|
||||
table used as a dropdown source, not a rare shape).
|
||||
|
||||
Record<never, never> is the type with the same intent - "no query
|
||||
columns" - but the mapped-type domain is `never`, so it has no keys at
|
||||
all rather than "every key, mapped to never": it behaves as the empty
|
||||
object type `{}` under intersection, leaving PageQuery's own pageIndex/
|
||||
pageSize untouched, and confirmed separately not to trip
|
||||
no-empty-object-type either (it is a generic instantiation, not a
|
||||
literal `{}` type annotation).
|
||||
*/ -}}
|
||||
export type {{.ClassName}}Query = Record<never, never>
|
||||
{{- end}}
|
||||
|
||||
export function list{{.ClassName}}(query: {{.ClassName}}Query & PageQuery) {
|
||||
return request<ApiResponse<PageResult<{{.ClassName}}>>>({
|
||||
url: '/api/v1/{{.ModuleName}}',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
export function get{{.ClassName}}({{.PkJsonField}}: {{$pkType}}) {
|
||||
return request<ApiResponse<{{.ClassName}}>>({
|
||||
url: '/api/v1/{{.ModuleName}}/' + {{.PkJsonField}},
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function add{{.ClassName}}(data: {{.ClassName}}) {
|
||||
return request<ApiResponse<{{.ClassName}}>>({
|
||||
url: '/api/v1/{{.ModuleName}}',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function update{{.ClassName}}(data: {{.ClassName}}) {
|
||||
return request<ApiResponse<{{.ClassName}}>>({
|
||||
url: '/api/v1/{{.ModuleName}}/' + data.{{.PkJsonField}},
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function del{{.ClassName}}(ids: Id[]) {
|
||||
return request<ApiResponse<null>>({
|
||||
url: '/api/v1/{{.ModuleName}}',
|
||||
method: 'delete',
|
||||
data: { ids: ids.map(Number) }
|
||||
})
|
||||
}
|
||||
+377
-467
@@ -1,479 +1,389 @@
|
||||
{{$tableComment:=.TableComment}}
|
||||
{{- /*
|
||||
Vue 3 + Element Plus + TypeScript list page (PRD 010, F4).
|
||||
|
||||
Shape matches go-admin-ui/src/views/demo/product/index.vue, the reference
|
||||
page AGENTS.md names: PageContainer + ProTable + useTable/useForm/useRemove,
|
||||
<script setup lang="ts">. The old template produced slot-scope/.sync/.native
|
||||
syntax that Vue 3 removed outright (PRD 010 G1) -- this is not a patch on
|
||||
that file, it is a different template for a different framework version.
|
||||
|
||||
Every label goes through $t('gen.{PackageName}.{BusinessName}.{JsonField}'),
|
||||
never a literal ColumnComment -- see src/lang/{locale}/gen/index.ts (F9) for
|
||||
how that namespace is loaded. This is also why the file must not contain a
|
||||
literal CJK character anywhere, comments included: D10's acceptance check is
|
||||
a bare regex scan of the rendered output with no exception for "but this one
|
||||
is a comment", so a Chinese aside here would fail the same test a stray
|
||||
placeholder="{{"{{"}}.ColumnComment{{"}}"}}" would.
|
||||
|
||||
HtmlType has seven stored values (PRD 010 G8) and only four render here on
|
||||
purpose: checkbox and datetime became selectable in the F7 front-end change
|
||||
(editTable.vue), so they get a branch; file stays disabled there, but a row
|
||||
imported or edited before that change can still carry "file" or any other
|
||||
value this template does not know -- the final branch below renders those,
|
||||
and anything else future work introduces, as a plain input rather than
|
||||
emitting nothing (PRD 010 phase-3 constraint #1: a silently empty field is
|
||||
worse than a plain one).
|
||||
*/ -}}
|
||||
{{- $package := .PackageName -}}
|
||||
{{- $business := .BusinessName -}}
|
||||
{{- /*
|
||||
Whether any column needs a given import, computed once by walking .Columns
|
||||
rather than at each usage site -- text/template has no way to ask "did the
|
||||
loop below already import this", so the alternative is repeating the same
|
||||
import line once per matching column. "$var = value" (not ":=") reassigns an
|
||||
outer-scope variable from inside a range -- a text/template feature since
|
||||
Go 1.11, needed here because a range body cannot otherwise leave a mark on
|
||||
anything outside itself.
|
||||
|
||||
Each condition below must match, term for term, the condition guarding the
|
||||
markup or script that actually consumes the import -- not just "this column
|
||||
has a DictType/FkTableName", which is necessary but not sufficient. A column
|
||||
can carry dictionary or foreign-key metadata that no rendered branch reads:
|
||||
FkTableName/DictType lose to each other by priority (FK wins search, list
|
||||
and the form's select branch; the form's radio branch never looks at FK at
|
||||
all), and a column can carry either one while being neither queryable nor
|
||||
listed nor an insertable select/radio -- created_at/updated_at are exactly
|
||||
this: sys_tables.go assigns HtmlType "datetime" to any timestamp/datetime
|
||||
column on import whether or not it ever reaches IsList, because GetList's
|
||||
audit-column exclusion is a separate, later step. Get a term here wrong in
|
||||
either direction and either an import goes unused (no-unused-vars) or a real
|
||||
usage silently loses its import (a ReferenceError this template cannot see
|
||||
coming, since Vue components are the last stage that runs).
|
||||
*/ -}}
|
||||
{{- $hasDict := false -}}
|
||||
{{- $hasDictList := false -}}
|
||||
{{- $hasFk := false -}}
|
||||
{{- $hasDatetime := false -}}
|
||||
{{- $hasRules := false -}}
|
||||
{{- $hasQuery := false -}}
|
||||
{{- $pkType := "number" -}}
|
||||
{{- range .Columns -}}
|
||||
{{- $dictUsed := and (ne .DictType "") (or (and (eq .IsQuery "1") (eq .FkTableName "")) (and (eq .IsList "1") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "select") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "radio"))) -}}
|
||||
{{- $fkUsed := and (ne .FkTableName "") (or (eq .IsQuery "1") (eq .IsList "1") (and (eq .IsInsert "1") (eq .HtmlType "select"))) -}}
|
||||
{{- if $dictUsed }}{{$hasDict = true}}{{end -}}
|
||||
{{- if and (eq .IsList "1") (eq .FkTableName "") (ne .DictType "") }}{{$hasDictList = true}}{{end -}}
|
||||
{{- if $fkUsed }}{{$hasFk = true}}{{end -}}
|
||||
{{- if and (eq .IsList "1") (eq .FkTableName "") (eq .DictType "") (eq .HtmlType "datetime") }}{{$hasDatetime = true}}{{end -}}
|
||||
{{- if eq .IsQuery "1" }}{{$hasQuery = true}}{{end -}}
|
||||
{{- if and (eq .IsInsert "1") (eq .IsRequired "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy") }}{{$hasRules = true}}{{end -}}
|
||||
{{- if and .Pk (eq .GoType "string") }}{{$pkType = "string"}}{{end -}}
|
||||
{{- end -}}
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<el-form ref="queryForm" :model="queryParams" :inline="true" label-width="68px">
|
||||
{{range .Columns}}
|
||||
{{- $x := .IsQuery -}}
|
||||
{{- if (eq $x "1") -}}
|
||||
<el-form-item label="{{.ColumnComment}}" prop="{{.JsonField}}">
|
||||
{{- if ne .FkTableName "" -}}
|
||||
<el-select v-model="queryParams.{{.JsonField}}"
|
||||
placeholder="请选择" clearable size="small" {{if eq .IsEdit "false" -}} :disabled="isEdit" {{- end }}>
|
||||
<el-option
|
||||
v-for="dict in {{.JsonField}}Options"
|
||||
:key="dict.key"
|
||||
:label="dict.value"
|
||||
:value="dict.key"
|
||||
/>
|
||||
</el-select>
|
||||
{{- else -}}
|
||||
{{if eq .DictType "" -}}
|
||||
<el-input v-model="queryParams.{{.JsonField}}" placeholder="请输入{{.ColumnComment}}" clearable
|
||||
size="small" @keyup.enter.native="handleQuery"/>
|
||||
{{- else -}}
|
||||
<el-select v-model="queryParams.{{.JsonField}}"
|
||||
placeholder="{{$tableComment}}{{.ColumnComment}}" clearable size="small">
|
||||
<el-option
|
||||
v-for="dict in {{.JsonField}}Options"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
</el-form-item>
|
||||
{{end}}
|
||||
{{- end }}
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<PageContainer>
|
||||
<ProTable :table="table" selection row-key="{{.PkJsonField}}">
|
||||
{{- if $hasQuery}}
|
||||
<template #search>
|
||||
{{- range .Columns}}
|
||||
{{- if eq .IsQuery "1"}}
|
||||
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
|
||||
<el-form-item :label="$t('{{$key}}')">
|
||||
{{- if ne .FkTableName ""}}
|
||||
<el-select v-model="table.query.{{.JsonField}}" clearable :placeholder="$t('common.selectPlaceholder')">
|
||||
<el-option
|
||||
v-for="item in {{.JsonField}}FkOptions"
|
||||
:key="item.{{.FkLabelId}}"
|
||||
:label="item.{{.FkLabelName}}"
|
||||
:value="item.{{.FkLabelId}}"
|
||||
/>
|
||||
</el-select>
|
||||
{{- else if ne .DictType ""}}
|
||||
<el-select v-model="table.query.{{.JsonField}}" clearable :placeholder="$t('common.selectPlaceholder')">
|
||||
<el-option
|
||||
v-for="dict in {{.JsonField}}DictOptions"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
{{- else if eq .HtmlType "datetime"}}
|
||||
<el-date-picker
|
||||
v-model="table.query.{{.JsonField}}"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD[T]HH:mm:ssZ"
|
||||
clearable
|
||||
/>
|
||||
{{- else}}
|
||||
<el-input v-model="table.query.{{.JsonField}}" clearable />
|
||||
{{- end}}
|
||||
</el-form-item>
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
</template>
|
||||
{{- end}}
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:add']"
|
||||
type="primary"
|
||||
icon="el-icon-plus"
|
||||
size="mini"
|
||||
@click="handleAdd"
|
||||
>新增
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:edit']"
|
||||
type="success"
|
||||
icon="el-icon-edit"
|
||||
size="mini"
|
||||
:disabled="single"
|
||||
@click="handleUpdate"
|
||||
>修改
|
||||
</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']"
|
||||
type="danger"
|
||||
icon="el-icon-delete"
|
||||
size="mini"
|
||||
:disabled="multiple"
|
||||
@click="handleDelete"
|
||||
>删除
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<template #toolbar>
|
||||
<el-button v-permisaction="['{{.PackageName}}:{{.BusinessName}}:add']" type="primary" @click="form.openCreate()">
|
||||
{{ "{{" }} $t('common.add') {{ "}}" }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']"
|
||||
type="danger"
|
||||
plain
|
||||
:disabled="table.multiple"
|
||||
@click="remove(table.selectedIds)"
|
||||
>
|
||||
{{ "{{" }} $t('common.delete') {{ "}}" }}
|
||||
</el-button>
|
||||
</template>
|
||||
{{- range .Columns}}
|
||||
{{- if eq .IsList "1"}}
|
||||
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
|
||||
{{- if ne .FkTableName ""}}
|
||||
|
||||
<el-table v-loading="loading" :data="{{.BusinessName}}List" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center"/>
|
||||
{{- range .Columns -}}
|
||||
{{- $x := .IsList -}}
|
||||
{{- if (eq $x "1") }}
|
||||
{{- if ne .FkTableName "" -}}
|
||||
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}" :formatter="{{.JsonField}}Format" width="100">
|
||||
<template slot-scope="scope">
|
||||
{{ "{{" }} {{.JsonField}}Format(scope.row) {{"}}"}}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('{{$key}}')" min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}120{{end}}" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ "{{" }} {{.JsonField}}Label(row.{{.JsonField}}) {{ "}}" }}</template>
|
||||
</el-table-column>
|
||||
{{- else if ne .DictType ""}}
|
||||
|
||||
{{- else -}}
|
||||
{{- if ne .DictType "" -}}
|
||||
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}"
|
||||
:formatter="{{.JsonField}}Format" width="100">
|
||||
<template slot-scope="scope">
|
||||
{{ "{{" }} {{.JsonField}}Format(scope.row) {{"}}"}}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('{{$key}}')" min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}120{{end}}">
|
||||
<template #default="{ row }">{{ "{{" }} dictLabel({{.JsonField}}DictOptions, row.{{.JsonField}}) {{ "}}" }}</template>
|
||||
</el-table-column>
|
||||
{{- else if eq .HtmlType "datetime"}}
|
||||
|
||||
{{- end -}}
|
||||
{{- if eq .DictType "" -}}
|
||||
{{- if eq .HtmlType "datetime" -}}
|
||||
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}"
|
||||
:show-overflow-tooltip="true">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ "{{" }} parseTime(scope.row.{{.JsonField}}) {{"}}"}}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
{{- else -}}
|
||||
<el-table-column label="{{.ColumnComment}}" align="center" prop="{{.JsonField}}"
|
||||
:show-overflow-tooltip="true"/>
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
slot="reference"
|
||||
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:edit']"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-edit"
|
||||
@click="handleUpdate(scope.row)"
|
||||
>修改
|
||||
</el-button>
|
||||
<el-popconfirm
|
||||
class="delete-popconfirm"
|
||||
title="确认要删除吗?"
|
||||
confirm-button-text="删除"
|
||||
@confirm="handleDelete(scope.row)"
|
||||
>
|
||||
<el-button
|
||||
slot="reference"
|
||||
v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-delete"
|
||||
>删除
|
||||
</el-button>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-table-column :label="$t('{{$key}}')" min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}110{{end}}">
|
||||
<template #default="{ row }"><DateCell :value="row.{{.JsonField}}" /></template>
|
||||
</el-table-column>
|
||||
{{- else}}
|
||||
|
||||
<pagination
|
||||
v-show="total>0"
|
||||
:total="total"
|
||||
:page.sync="queryParams.pageIndex"
|
||||
:limit.sync="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
<el-table-column
|
||||
:label="$t('{{$key}}')"
|
||||
prop="{{.JsonField}}"
|
||||
min-width="{{if .ColWidth}}{{.ColWidth}}{{else}}120{{end}}"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
<!-- 添加或修改对话框 -->
|
||||
<el-dialog :title="title" :visible.sync="open" width="500px">
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="80px">
|
||||
{{ range .Columns }}
|
||||
{{- $x := .IsInsert -}}
|
||||
{{- if (eq $x "1") -}}
|
||||
{{- if (.Pk) }}
|
||||
{{- else if eq .GoField "CreatedAt" -}}
|
||||
{{- else if eq .GoField "UpdatedAt" -}}
|
||||
{{- else if eq .GoField "DeletedAt" -}}
|
||||
{{- else if eq .GoField "UpdateBy" -}}
|
||||
{{- else if eq .GoField "CreateBy" -}}
|
||||
{{- else }}
|
||||
<el-form-item label="{{.ColumnComment}}" prop="{{.JsonField}}">
|
||||
{{ if eq "input" .HtmlType -}}
|
||||
<el-input v-model{{if eq .GoType "int64" -}}.number{{- end}}="form.{{.JsonField}}" placeholder="{{.ColumnComment}}"
|
||||
{{if eq .IsEdit "false" -}}:disabled="isEdit" {{- end}}/>
|
||||
{{- else if eq "select" .HtmlType -}}
|
||||
{{- if ne .FkTableName "" -}}
|
||||
<el-select v-model="form.{{.JsonField}}"
|
||||
placeholder="请选择" {{if eq .IsEdit "false" -}} :disabled="isEdit" {{- end }}>
|
||||
<el-option
|
||||
v-for="dict in {{.JsonField}}Options"
|
||||
:key="dict.key"
|
||||
:label="dict.value"
|
||||
:value="dict.key"
|
||||
/>
|
||||
</el-select>
|
||||
{{- else -}}
|
||||
<el-select v-model="form.{{.JsonField}}"
|
||||
placeholder="请选择" {{if eq .IsEdit "false" -}} :disabled="isEdit" {{- end }}>
|
||||
<el-option
|
||||
v-for="dict in {{.JsonField}}Options"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
{{- end -}}
|
||||
{{- else if eq "radio" .HtmlType -}}
|
||||
<el-radio-group v-model="form.{{.JsonField}}">
|
||||
<el-radio
|
||||
v-for="dict in {{.JsonField}}Options"
|
||||
:key="dict.value"
|
||||
:label="dict.value"
|
||||
>{{"{{"}} dict.label {{"}}"}}</el-radio>
|
||||
</el-radio-group>
|
||||
{{- else if eq "file" .HtmlType -}}
|
||||
<el-input
|
||||
v-model="form.{{.JsonField}}"
|
||||
placeholder="图片"
|
||||
/>
|
||||
<el-button type="primary" @click="fileShow{{.GoField}}">选择文件</el-button>
|
||||
{{- else if eq "datetime" .HtmlType -}}
|
||||
<el-date-picker
|
||||
v-model="form.{{.JsonField}}"
|
||||
type="datetime"
|
||||
placeholder="选择日期">
|
||||
</el-date-picker>
|
||||
{{- else if eq "textarea" .HtmlType -}}
|
||||
<el-input
|
||||
v-model="form.{{.JsonField}}"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入内容">
|
||||
</el-input>
|
||||
{{- end }}
|
||||
</el-form-item>
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
</el-form>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
<template #actions="{ row }">
|
||||
<el-button v-permisaction="['{{.PackageName}}:{{.BusinessName}}:edit']" link type="primary" @click="form.openEdit(row)">
|
||||
{{ "{{" }} $t('common.edit') {{ "}}" }}
|
||||
</el-button>
|
||||
<el-button v-permisaction="['{{.PackageName}}:{{.BusinessName}}:remove']" link type="danger" @click="remove(row.{{.PkJsonField}})">
|
||||
{{ "{{" }} $t('common.delete') {{ "}}" }}
|
||||
</el-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
|
||||
<el-dialog
|
||||
v-model="form.visible"
|
||||
:title="form.title"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
@closed="form.reset"
|
||||
>
|
||||
<el-form
|
||||
:ref="form.bindFormRef"
|
||||
v-loading="form.loading"
|
||||
:model="form.model"
|
||||
{{- if $hasRules}}
|
||||
:rules="form.rules"
|
||||
{{- end}}
|
||||
label-width="100px"
|
||||
>
|
||||
{{- range .Columns}}
|
||||
{{- if and (eq .IsInsert "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy")}}
|
||||
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
|
||||
<el-form-item :label="$t('{{$key}}')" prop="{{.JsonField}}">
|
||||
{{- if eq .HtmlType "select"}}
|
||||
{{- if ne .FkTableName ""}}
|
||||
<el-select v-model="form.model.{{.JsonField}}" :placeholder="$t('common.selectPlaceholder')">
|
||||
<el-option
|
||||
v-for="item in {{.JsonField}}FkOptions"
|
||||
:key="item.{{.FkLabelId}}"
|
||||
:label="item.{{.FkLabelName}}"
|
||||
:value="item.{{.FkLabelId}}"
|
||||
/>
|
||||
</el-select>
|
||||
{{- else if ne .DictType ""}}
|
||||
<el-select v-model="form.model.{{.JsonField}}" :placeholder="$t('common.selectPlaceholder')">
|
||||
<el-option
|
||||
v-for="dict in {{.JsonField}}DictOptions"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
{{- else}}
|
||||
<el-input v-model="form.model.{{.JsonField}}" />
|
||||
{{- end}}
|
||||
{{- else if eq .HtmlType "radio"}}
|
||||
{{- if ne .DictType ""}}
|
||||
<el-radio-group v-model="form.model.{{.JsonField}}">
|
||||
<el-radio v-for="dict in {{.JsonField}}DictOptions" :key="dict.value" :value="dict.value">
|
||||
{{ "{{" }} dict.label {{ "}}" }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
{{- else}}
|
||||
<el-input v-model="form.model.{{.JsonField}}" />
|
||||
{{- end}}
|
||||
{{- else if eq .HtmlType "checkbox"}}
|
||||
<el-checkbox v-model="form.model.{{.JsonField}}" true-value="1" false-value="0" />
|
||||
{{- else if eq .HtmlType "datetime"}}
|
||||
<el-date-picker
|
||||
v-model="form.model.{{.JsonField}}"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD[T]HH:mm:ssZ"
|
||||
/>
|
||||
{{- else if eq .HtmlType "textarea"}}
|
||||
<el-input v-model="form.model.{{.JsonField}}" type="textarea" :rows="2" />
|
||||
{{- else}}
|
||||
<el-input v-model="form.model.{{.JsonField}}" />
|
||||
{{- end}}
|
||||
</el-form-item>
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="form.close">{{ "{{" }} $t('common.dialogCancel') {{ "}}" }}</el-button>
|
||||
<el-button type="primary" :loading="form.submitting" @click="form.submit">
|
||||
{{ "{{" }} $t('common.dialogConfirm') {{ "}}" }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {add{{.ClassName}}, del{{.ClassName}}, get{{.ClassName}}, list{{.ClassName}}, update{{.ClassName}}} from '@/api/{{ .PackageName}}/{{ .MLTBName}}'
|
||||
{{ $package:=.PackageName }}
|
||||
{{range .Columns}}
|
||||
{{- if ne .FkTableName "" -}}
|
||||
import {list{{.FkTableNameClass}} } from '@/api/{{ $package }}/{{ .FkTableNamePackage}}'
|
||||
{{ end -}}
|
||||
{{- end -}}
|
||||
<script setup lang="ts">
|
||||
{{- if $hasRules}}
|
||||
import { computed } from 'vue'
|
||||
{{- end}}
|
||||
{{- if $hasFk}}
|
||||
import { ref, onMounted } from 'vue'
|
||||
{{- end}}
|
||||
{{- if $hasRules}}
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { FormRules } from 'element-plus'
|
||||
{{- end}}
|
||||
import PageContainer from '@/components/PageContainer/index.vue'
|
||||
import ProTable from '@/components/ProTable/index.vue'
|
||||
{{- if $hasDatetime}}
|
||||
import DateCell from '@/components/DateCell/index.vue'
|
||||
{{- end}}
|
||||
{{- if $hasDict}}
|
||||
{{- if $hasDictList}}
|
||||
import { useTable, useForm, useRemove, useDict, dictLabel } from '@/composables'
|
||||
{{- else}}
|
||||
import { useTable, useForm, useRemove, useDict } from '@/composables'
|
||||
{{- end}}
|
||||
{{- else}}
|
||||
import { useTable, useForm, useRemove } from '@/composables'
|
||||
{{- end}}
|
||||
import {
|
||||
add{{.ClassName}}, del{{.ClassName}}, get{{.ClassName}}, list{{.ClassName}}, update{{.ClassName}}
|
||||
} from '@/api/{{.PackageName}}/{{.MLTBName}}'
|
||||
import type { {{.ClassName}}, {{.ClassName}}Query } from '@/api/{{.PackageName}}/{{.MLTBName}}'
|
||||
{{- /*
|
||||
Two columns pointing at the same foreign table must not import it twice --
|
||||
"one FK-configured column" was never the same thing as "one distinct target
|
||||
table", and gen.go has no concept of a table's FK targets being unique.
|
||||
text/template has no set to check membership in, so the dedup is a nested
|
||||
range: a column only imports its target if no earlier, equally-used column
|
||||
already claimed the same FkTableNameClass. $fkUsed is repeated here (it also
|
||||
guards the const declarations above) because a column with FkTableName set
|
||||
but reaching none of them -- unqueried, unlisted, not an insert select --
|
||||
has nothing that would use the import either.
|
||||
*/ -}}
|
||||
{{- range $i, $col := .Columns}}
|
||||
{{- $fkUsed := and (ne $col.FkTableName "") (or (eq $col.IsQuery "1") (eq $col.IsList "1") (and (eq $col.IsInsert "1") (eq $col.HtmlType "select"))) -}}
|
||||
{{- if $fkUsed}}
|
||||
{{- $alreadyImported := false -}}
|
||||
{{- range $j, $prior := $.Columns}}
|
||||
{{- if lt $j $i}}
|
||||
{{- $priorUsed := and (ne $prior.FkTableName "") (or (eq $prior.IsQuery "1") (eq $prior.IsList "1") (and (eq $prior.IsInsert "1") (eq $prior.HtmlType "select"))) -}}
|
||||
{{- if and $priorUsed (eq $prior.FkTableNameClass $col.FkTableNameClass) }}{{$alreadyImported = true}}{{end -}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- if not $alreadyImported}}
|
||||
import { list{{$col.FkTableNameClass}} } from '@/api/{{$package}}/{{$col.FkTableNamePackage}}'
|
||||
import type { {{$col.FkTableNameClass}} } from '@/api/{{$package}}/{{$col.FkTableNamePackage}}'
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- /*
|
||||
Manage suffix, not just ClassName: this must match gen.go's
|
||||
Cmenu.MenuName = tab.ClassName + "Manage" byte for byte (PRD 010 R4), or
|
||||
keep-alive's include list -- built from menu_name -- never matches this
|
||||
component's name and the page never caches. The old template wrote
|
||||
name: '{ClassName}' with no suffix; the mismatch went unnoticed because
|
||||
stores/permission.ts's loadView() rewrites the rendered component's name to
|
||||
menu_name at runtime regardless of what defineOptions said (PRD 010 G7).
|
||||
That fallback stays in place after this change -- it is not this template's
|
||||
to remove -- but the value declared here should be right regardless of it.
|
||||
*/}}
|
||||
|
||||
export default {
|
||||
name: '{{.ClassName}}',
|
||||
components: {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// 遮罩层
|
||||
loading: true,
|
||||
// 选中数组
|
||||
ids: [],
|
||||
// 非单个禁用
|
||||
single: true,
|
||||
// 非多个禁用
|
||||
multiple: true,
|
||||
// 总条数
|
||||
total: 0,
|
||||
// 弹出层标题
|
||||
title: '',
|
||||
// 是否显示弹出层
|
||||
open: false,
|
||||
isEdit: false,
|
||||
// 类型数据字典
|
||||
typeOptions: [],
|
||||
{{.BusinessName}}List: [],
|
||||
{{range .Columns}}
|
||||
{{- if ne .DictType "" -}}
|
||||
{{.JsonField}}Options: [],
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
// 关系表类型
|
||||
{{range .Columns}}
|
||||
{{- if ne .FkTableName "" -}}
|
||||
{{.JsonField}}Options :[],
|
||||
{{ end -}}
|
||||
{{- end }}
|
||||
// 查询参数
|
||||
queryParams: {
|
||||
pageIndex: 1,
|
||||
pageSize: 10,
|
||||
{{ range .Columns }}
|
||||
{{- if (.IsQuery) -}}
|
||||
{{.JsonField}}:undefined,
|
||||
{{ end -}}
|
||||
{{- end }}
|
||||
},
|
||||
// 表单参数
|
||||
form: {
|
||||
},
|
||||
// 表单校验
|
||||
rules: {
|
||||
{{- range .Columns -}}
|
||||
{{- $x := .IsQuery -}}
|
||||
{{- if (eq $x "1") -}}
|
||||
{{.JsonField}}: [ {required: true, message: '{{.ColumnComment}}不能为空', trigger: 'blur'} ],
|
||||
{{ end }}
|
||||
{{- end -}}
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
{{range .Columns}}
|
||||
{{- if ne .DictType "" -}}
|
||||
this.getDicts('{{.DictType}}').then(response => {
|
||||
this.{{.JsonField}}Options = response.data
|
||||
})
|
||||
{{ end -}}
|
||||
{{- if ne .FkTableName "" -}}
|
||||
this.get{{.FkTableNameClass}}Items()
|
||||
{{ end -}}
|
||||
{{- end -}}
|
||||
},
|
||||
methods: {
|
||||
/** 查询参数列表 */
|
||||
getList() {
|
||||
this.loading = true
|
||||
list{{.ClassName}}(this.addDateRange(this.queryParams, this.dateRange)).then(response => {
|
||||
this.{{.BusinessName}}List = response.data.list
|
||||
this.total = response.data.count
|
||||
this.loading = false
|
||||
}
|
||||
)
|
||||
},
|
||||
// 取消按钮
|
||||
cancel() {
|
||||
this.open = false
|
||||
this.reset()
|
||||
},
|
||||
// 表单重置
|
||||
reset() {
|
||||
this.form = {
|
||||
{{ range .Columns}}
|
||||
{{- $x := .IsInsert -}}
|
||||
{{- if (eq $x "1") -}}
|
||||
{{- if eq .GoField "CreatedAt" -}}
|
||||
{{- else if eq .GoField "UpdatedAt" -}}
|
||||
{{- else if eq .GoField "DeletedAt" -}}
|
||||
{{- else if eq .GoField "UpdateBy" -}}
|
||||
{{- else if eq .GoField "CreateBy" -}}
|
||||
{{- else }}
|
||||
{{.JsonField}}: undefined,
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
}
|
||||
this.resetForm('form')
|
||||
},
|
||||
getImgList: function() {
|
||||
this.form[this.fileIndex] = this.$refs['fileChoose'].resultList[0].fullUrl
|
||||
},
|
||||
fileClose: function() {
|
||||
this.fileOpen = false
|
||||
},
|
||||
{{range .Columns}}
|
||||
{{- if ne .DictType "" -}}
|
||||
{{.JsonField}}Format(row) {
|
||||
return this.selectDictLabel(this.{{.JsonField}}Options, row.{{.JsonField}})
|
||||
},
|
||||
{{ end -}}
|
||||
{{- if ne .FkTableName "" -}}
|
||||
{{.JsonField}}Format(row) {
|
||||
return this.selectItemsLabel(this.{{.JsonField}}Options, row.{{.JsonField}})
|
||||
},
|
||||
{{ end -}}
|
||||
{{- end -}}
|
||||
// 关系
|
||||
{{range .Columns}}
|
||||
{{- if ne .FkTableName "" -}}
|
||||
get{{.FkTableNameClass}}Items() {
|
||||
this.getItems(list{{.FkTableNameClass}}, undefined).then(res => {
|
||||
this.{{.JsonField}}Options = this.setItems(res, '{{.FkLabelId}}', '{{.FkLabelName}}')
|
||||
})
|
||||
},
|
||||
{{ end -}}
|
||||
{{- end -}}
|
||||
// 文件
|
||||
{{range .Columns}}
|
||||
{{- if eq .HtmlType "file" -}}
|
||||
fileShow{{.GoField}}: function() {
|
||||
this.fileOpen = true
|
||||
this.fileIndex = '{{.JsonField}}'
|
||||
},
|
||||
{{ end -}}
|
||||
{{- end -}}
|
||||
/** 搜索按钮操作 */
|
||||
handleQuery() {
|
||||
this.queryParams.pageIndex = 1
|
||||
this.getList()
|
||||
},
|
||||
/** 重置按钮操作 */
|
||||
resetQuery() {
|
||||
this.dateRange = []
|
||||
this.resetForm('queryForm')
|
||||
this.handleQuery()
|
||||
},
|
||||
/** 新增按钮操作 */
|
||||
handleAdd() {
|
||||
this.reset()
|
||||
this.open = true
|
||||
this.title = '添加{{.TableComment}}'
|
||||
this.isEdit = false
|
||||
},
|
||||
// 多选框选中数据
|
||||
handleSelectionChange(selection) {
|
||||
this.ids = selection.map(item => item.{{.PkJsonField}})
|
||||
this.single = selection.length !== 1
|
||||
this.multiple = !selection.length
|
||||
},
|
||||
/** 修改按钮操作 */
|
||||
handleUpdate(row) {
|
||||
this.reset()
|
||||
const {{.PkJsonField}} =
|
||||
row.{{.PkJsonField}} || this.ids
|
||||
get{{.ClassName}}({{.PkJsonField}}).then(response => {
|
||||
this.form = response.data
|
||||
this.open = true
|
||||
this.title = '修改{{.TableComment}}'
|
||||
this.isEdit = true
|
||||
})
|
||||
},
|
||||
/** 提交按钮 */
|
||||
submitForm: function () {
|
||||
this.$refs['form'].validate(valid => {
|
||||
if (valid) {
|
||||
if (this.form.{{.PkJsonField}} !== undefined) {
|
||||
update{{.ClassName}}(this.form).then(response => {
|
||||
if (response.code === 200) {
|
||||
this.msgSuccess(response.msg)
|
||||
this.open = false
|
||||
this.getList()
|
||||
} else {
|
||||
this.msgError(response.msg)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
add{{.ClassName}}(this.form).then(response => {
|
||||
if (response.code === 200) {
|
||||
this.msgSuccess(response.msg)
|
||||
this.open = false
|
||||
this.getList()
|
||||
} else {
|
||||
this.msgError(response.msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
/** 删除按钮操作 */
|
||||
handleDelete(row) {
|
||||
var Ids = (row.{{.PkJsonField}} && [row.{{.PkJsonField}}]) || this.ids
|
||||
defineOptions({ name: '{{.ClassName}}Manage' })
|
||||
{{- range .Columns}}
|
||||
{{- $dictUsed := and (ne .DictType "") (or (and (eq .IsQuery "1") (eq .FkTableName "")) (and (eq .IsList "1") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "select") (eq .FkTableName "")) (and (eq .IsInsert "1") (eq .HtmlType "radio"))) -}}
|
||||
{{- $fkUsed := and (ne .FkTableName "") (or (eq .IsQuery "1") (eq .IsList "1") (and (eq .IsInsert "1") (eq .HtmlType "select"))) -}}
|
||||
{{- if $dictUsed}}
|
||||
|
||||
this.$confirm('是否确认删除编号为"' + Ids + '"的数据项?', '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(function () {
|
||||
return del{{.ClassName}}( { 'ids': Ids })
|
||||
}).then((response) => {
|
||||
if (response.code === 200) {
|
||||
this.msgSuccess(response.msg)
|
||||
this.open = false
|
||||
this.getList()
|
||||
} else {
|
||||
this.msgError(response.msg)
|
||||
}
|
||||
}).catch(function () {
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const { {{.DictType}}: {{.JsonField}}DictOptions } = useDict('{{.DictType}}')
|
||||
{{- end}}
|
||||
{{- if $fkUsed}}
|
||||
|
||||
const {{.JsonField}}FkOptions = ref<{{.FkTableNameClass}}[]>([])
|
||||
onMounted(async() => {
|
||||
const res = await list{{.FkTableNameClass}}({ pageIndex: 1, pageSize: 100 })
|
||||
{{.JsonField}}FkOptions.value = res.data?.list ?? []
|
||||
})
|
||||
{{- if eq .IsList "1"}}
|
||||
const {{.JsonField}}Label = (value: unknown) =>
|
||||
{{.JsonField}}FkOptions.value.find(item => item.{{.FkLabelId}} === value)?.{{.FkLabelName}} ?? value
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- /*
|
||||
Every object literal below is built on one line, joined with ", " through a
|
||||
$first flag rather than one field per line with a trailing comma after each:
|
||||
comma-dangle is "never" (no comma before the closing brace) and comma-style
|
||||
is "last" (a comma may not open a line), and text/template has no arithmetic
|
||||
to compute "is this the last matching column" up front -- knowing that would
|
||||
be what a one-field-per-line, trailing-comma-free rendering needs instead.
|
||||
*/}}
|
||||
|
||||
const table = useTable<{{.ClassName}}, {{.ClassName}}Query>({
|
||||
api: list{{.ClassName}},
|
||||
idKey: '{{.PkJsonField}}'
|
||||
{{- if $hasQuery}},
|
||||
defaultQuery: () => ({{"{"}} {{$qFirst := true}}{{range .Columns}}{{if eq .IsQuery "1"}}{{if $qFirst}}{{$qFirst = false}}{{else}}, {{end}}{{.JsonField}}: undefined{{end}}{{end}} {{"}"}})
|
||||
{{- end}}
|
||||
})
|
||||
{{- if $hasRules}}
|
||||
|
||||
const { t } = useI18n()
|
||||
{{- /*
|
||||
Built from the same field-label key rather than a dedicated
|
||||
gen.{pkg}.{biz}.rules.{field} key: R3 derives one key per field from
|
||||
PackageName+BusinessName+JsonField, and a second, validation-only key per
|
||||
required field would double the language pack's surface for a message that
|
||||
reads fine as the field name alone in the space Element Plus renders it --
|
||||
directly under the labelled field it failed to validate.
|
||||
*/}}
|
||||
|
||||
const rules = computed<FormRules>(() => ({ {{$rFirst := true}}
|
||||
{{- range .Columns}}
|
||||
{{- if and (eq .IsInsert "1") (eq .IsRequired "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy")}}
|
||||
{{- $key := printf "gen.%s.%s.%s" $package $business .JsonField}}
|
||||
{{- if $rFirst}}{{$rFirst = false}}{{else}},
|
||||
{{end}}{{.JsonField}}: [{ required: true, message: t('{{$key}}'), trigger: '{{if or (eq .HtmlType "select") (eq .HtmlType "radio") (eq .HtmlType "datetime") (eq .HtmlType "checkbox")}}change{{else}}blur{{end}}' }]
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}))
|
||||
{{- end}}
|
||||
|
||||
const form = useForm<{{.ClassName}}, {{$pkType}}>({
|
||||
defaultModel: () => ({{"{"}} {{.PkJsonField}}: undefined{{range .Columns}}{{if and (eq .IsInsert "1") (not .Pk) (ne .GoField "CreatedAt") (ne .GoField "UpdatedAt") (ne .GoField "DeletedAt") (ne .GoField "UpdateBy") (ne .GoField "CreateBy")}}, {{.JsonField}}: {{if eq .DefaultValue ""}}undefined{{else if eq .GoType "int"}}{{.DefaultValue}}{{else}}'{{js .DefaultValue}}'{{end}}{{end}}{{end}} {{"}"}}),
|
||||
idKey: '{{.PkJsonField}}',
|
||||
{{- if $hasRules}}
|
||||
rules,
|
||||
{{- end}}
|
||||
api: { get: get{{.ClassName}}, add: add{{.ClassName}}, update: update{{.ClassName}} },
|
||||
onSuccess: () => table.getList()
|
||||
})
|
||||
|
||||
const { remove } = useRemove({
|
||||
api: del{{.ClassName}},
|
||||
onSuccess: () => table.getList()
|
||||
})
|
||||
</script>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user